Динамические шрифты в OpenGL
Введение
Эта статья — результат моих попыток разобраться с выводом текста в OpenGL. Этот вопрос неизбежно встает для многих начинающих работать с OpenGL. Чтобы помочь другим избежать разложенных повсюду граблей, я опишу свои результаты и попытаюсь дать детальные разъяснения, что и как было сделано. Здесь преследуется две цели — создание небольшого руководства для новичков и систематизация результатов. А там, глядишь, умные люди прочитают и посоветуют что-то полезное.
Сразу оговорюсь — я не являюсь специалистом по компьютерной графике и опыт работы с OpenGL у меня весьма мал. Поэтому я охотно выслушаю замечания, пожелания и комментарии. Материал для экспериментов был почерпнут из чтения разнообразных руководств, а также из обсуждений на форумах. Я пытался найти какую-нибудь lightweight библиотечку или пример в виде нормально написанного кода, однако большинство найденных в сети примеров меня не устроили по тем или иным причинам (к тому же хотелось покопаться самостоятельно).
Проблема вывода текста и способы решения
Итак приступим. Суть проблемы заключается в том, что OpenGL API не имеет средств для непосредственной работы с текстом. Однако, как и полагается графическому API, он предоставляет всевозможные варианты вывода заданного изображения на экран. В нашем случае этим изображением является текст, который мы хотим получить на экране. Самым простым вариантом будет создать картинку, содержащую необходимый текст, и вывести ее на экран. Однако такой подход не дает возможности динамически формировать текст. Поэтому мы пойдем другим путем. Так как текст состоит из символов, начертание которых неизменно для конкретной гарнитуры, то можно хранить текстуру, содержащую все необходимые символы, и выводить текст, как последовательность прямоугольников считанных из текстуры. На рисунке [1] приведен пример такой текстуры для шрифта MS Sans Serif, нормальное начертание, размер 10 пунктов.
![[1] Текстура шрифта MS Sans Serif, 10pt](/resources/images/articles/gltext/ms10tex.gif)
[1] Текстура шрифта MS Sans Serif, 10pt
Если мы хотим набрать некую фразу, например «The quick brown fox jumps over the lazy dog», то можно просто составить необходимые символы вместе и сформировать строку. Пока не будем затрагивать технические подробности. Эта идея хороша на первый взгляд, однако имеет ряд недостатков. Во первых мы все еще ограничены набором символов из текстуры, и если мы хотим использовать несколько шрифтов, то нам требуется заранее создавать все необходимые текстуры. А хотелось бы иметь возможность динамически загружать используемые шрифты. Во вторых мы забыли о том факте, что при наборе текста расстояния между символами не остаются постоянными. Они зависят от выбранного начертания и от стоящих рядом символов. В типографике это изменение расстояния между определенными парами символов называется кернингом (kerning).
Соответственно, чтобы вывести текст, нам необходимо получить нужный глиф (растеризацию символа) и информацию о кернинге. К счастью любая система рендеринга шрифтов предоставляет всю необходимую информацию о шрифте и символах, и, в частности, способна растеризовать заданный символ непосредственно в память. Вооружившись этим знанием, мы перейдем к практической реализации.
Основная идея
В качестве системы рендеринга мы будем использовать встроенные функции по работе со шрифтами, которые предлагает нам Windows API. В качестве альтернативы сделаем поддержку библиотеки FreeType, которая переносится на большинство платформ и следовательно наш код можно будет использовать на тех же платформах. Выбор системы рендеринга задается установкой макро-определения _UI_FONTS_FREETYPE2 или _UI_FONTS_MSWIN.
Фактически, реализация основывается на функциях GetGlyphOutline для Windows API и FT_Glyph_To_Bitmap для FreeType2. Обе функции отвечают за растеризацию глифа и «рисуют» полученный результат в памяти компьютера. Нам лишь требуется считать этот глиф из памяти и сохранить в специально подготовленной OpenGL текстуре, путем вызова функции glTexSubImage2D. Помимо этого мы должны хранить размеры и типографские свойства глифа. Уже назревает необходимость выделить под глиф отдельный класс. Кернинг и прочие характеристики, присущие шрифту, считываются при установке шрифта, и, соответственно, появляется второй класс, описывающий загруженный шрифт. Заметим, что нет необходимости считывать весь шрифт в память, достаточно лишь создавать новый глиф в момент, когда он будет непосредственно использован. Это выглядит как кэширование графических изображений глифов и сводит к минимуму используемые ресурсы.
Глифы: классы и реализация
Мы пойдем по пути абстракций и будем разделять логическое описание глифа и его непосредственное физическое представление. В качестве последнего выступает изображение на текстуре, однако ничто не мешает использовать другие методики хранения и вывода информации. Не будем тянуть резину и приведем базовый класс для глифов.
![]() | Class: ui::abstract_glyph_t |
Ничего сложного в вышеприведенном классе нет. Более детально о характеристиках символов можно прочитать на любом популярном ресурсе о шрифтах. Теперь перейдем собственно к реализации текстурных глифов. Для этих целей служит следующий класс.
![]() | Class: ui::texture_glyph_t |
Рассмотрим код функции store_to_texture, которая, как следует из названия, сохраняет графическое изображение глифа в текстуре. Здесь и далее отдельные блоки кода дополнительно прокомментированы.
void texture_glyph_t::store_to_texture
(void* data, int bx, int by, GLsizei bw, GLsizei bh,
int tx, int ty, GLsizei tw, GLsizei th, int pitch)
{
// NOTE: FreeType2 bitmaps might use negative pitch on some systems,
// here we assume positive pitch only.
Я не знаю на каких именно системах память под данные растет в другую сторону (это соответствует отрицательным значениям pitch) и сознательно не стал обрабатывать этот случай.
glPushClientAttrib (GL_CLIENT_PIXEL_STORE_BIT) ;
glPixelStorei (GL_UNPACK_LSB_FIRST, GL_FALSE) ;
glPixelStorei (GL_UNPACK_ROW_LENGTH, bw) ;
Установим параметры данных. Картинка глифа кодируется в виде битовой карты MSB (most-significant bit first), и длина строки совпадает с шириной картинки.
#if defined(_UI_FONTS_FREETYPE2) && !defined(_UI_FONTS_MONOCHROME)
glPixelStorei (GL_UNPACK_ALIGNMENT, 1) ;
#else
glPixelStorei (GL_UNPACK_ALIGNMENT, pitch) ;
#endif
Я не смог победить и до конца разобраться со значением pitch в случае нормального режима рендеринга для не-монохромных картинок. По непонятным для меня причинами это значение всегда было равно ширине картинки, хотя при других установках я получал реальное выравнивание. Комментарии знающих людей приветствуются.
#ifdef _UI_FONTS_MONOCHROME
static GLfloat map[2] = { 0.0, 1.0 } ;
glPixelMapfv (GL_PIXEL_MAP_I_TO_A, 2, map) ;
glPixelTransferf (GL_MAP_COLOR, GL_TRUE) ;
glBindTexture (GL_TEXTURE_2D, m_tex) ;
glTexSubImage2D (GL_TEXTURE_2D, 0, tx, ty, bw, bh,
GL_COLOR_INDEX, GL_BITMAP, data) ;
#else
glBindTexture (GL_TEXTURE_2D, m_tex) ;
glTexSubImage2D (GL_TEXTURE_2D, 0, tx, ty, bw, bh,
GL_ALPHA, GL_UNSIGNED_BYTE, data) ;
#endif
glPopClientAttrib () ;
В случае монохромной картинки каждый пиксел кодируется одним битом, значение 1 соответствует цвету пера и значение 0 цвету фона. В случае картинки заданной градациями серого, количество бит на пиксел вычисляется автоматически на основании указанных выше параметров. В текстуре мы будем хранить не цвет (мы его не знаем), а уровень прозрачности. Поэтому для монохромной картинки мы указываем индексированный цвет и карту соответствий для значений alpha-канала. Во втором случае нас устроит параметр GL_ALPHA в качестве формата данных пикселей.
float tx0 = static_cast<float> (tx ) / static_cast<float> (tw) ;
float ty0 = static_cast<float> (ty ) / static_cast<float> (th) ;
float tx1 = static_cast<float> (tx + bw) / static_cast<float> (tw) ;
float ty1 = static_cast<float> (ty + bh) / static_cast<float> (th) ;
Вычислим реальные текстурные координаты (они задаются в интервале [0,1]).
m_list = glGenLists (1) ;
if (m_list == 0) return ; // TODO: error processing
glNewList (m_list, GL_COMPILE) ;
glBegin (GL_QUADS) ;
glTexCoord2f (tx0, ty0) ; glVertex2f (bx, -by) ;
glTexCoord2f (tx0, ty1) ; glVertex2f (bx, -by + bh) ;
glTexCoord2f (tx1, ty1) ; glVertex2f (bx + bw, -by + bh) ;
glTexCoord2f (tx1, ty0) ; glVertex2f (bx + bw, -by) ;
glEnd () ;
glEndList () ;
}
Создадим OpenGL command list для вывода глифа. Наличие подобных списков позволяет существенно ускорить процесс вывода.
Теперь мы должны дать ответ на вопрос — как создавать изображения для глифов. Это происходит в соответствующих конструкторах класса. Рассмотрим конструктор для FreeType глифов (я опускаю обработку ошибок и прочие, не имеющие отношения к делу, куски кода).
m_advance = static_cast<float> (glyph->advance.x) / 65536.0f ;
FT_BBox bbox ;
FT_Glyph_Get_CBox (glyph, FT_GLYPH_BBOX_SUBPIXELS, &bbox) ;
m_bbox.pos (point_t<float> (bbox.xMin / 64.0f, bbox.yMin / 64.0f)) ;
m_bbox.size (point_t<float> ((bbox.xMax - bbox.xMin) / 64.0f,
(bbox.yMax - bbox.yMin) / 64.0f)) ;
Сперва мы вычисляем геометрию глифа. Расстояние сдвига задается в виде 16.16 вектора с фиксированной точкой. Нас интересует лишь горизонтальная составляющая. Также получим размеры обрамляющего прямоугольника.
#ifdef _UI_FONTS_MONOCHROME
FT_Render_Mode mode = FT_RENDER_MODE_MONO ;
#else
FT_Render_Mode mode = FT_RENDER_MODE_NORMAL ;
#endif
if (glyph->format != FT_GLYPH_FORMAT_BITMAP)
{
FT_Error error = FT_Glyph_To_Bitmap (&glyph, mode, 0, 1) ;
if (error) return ;
}
FT_BitmapGlyph bitmap = reinterpret_cast<FT_BitmapGlyph> (glyph) ;
FT_Bitmap* source = &bitmap->bitmap ;
store_to_texture (source->buffer, bitmap->left, bitmap->top,
source->width, source->rows, tx, ty, tw, th,
source->pitch) ;
FT_Done_Glyph (glyph) ;
Установим режим рендеринга и получим bitmap для данного глифа. Теперь нам нужно лишь докопаться до реальных данных и параметров картинки и скормить все уже известному нам методу store_to_texture.
Теперь рассмотрим то же самое, но для Windows шрифтов.
#ifdef _UI_FONTS_MONOCHROME
int flag = GGO_BITMAP ;
#else
int flag = GGO_GRAY8_BITMAP ;
#endif
DWORD size = GetGlyphOutline (hdc, ch, flag, &gm, 0, 0, &mat) ;
if (size == GDI_ERROR) return ; // TODO: throw?
void* data = malloc (size) ;
if (GetGlyphOutline (hdc, ch, flag, &gm, size, data, &mat) == GDI_ERROR)
return ; // TODO: throw?
Укажем тип выходной картинки и вызовем GetGlyphOutline для получения необходимого размера буфера. Второй вызов вернет нам bitmap и сохранит его в выделенном буфере.
int bw = gm.gmBlackBoxX ;
int bh = gm.gmBlackBoxY ;
m_advance = gm.gmCellIncX ;
m_bbox.pos (point_t<float> (static_cast<float> (gm.gmptGlyphOrigin.x),
static_cast<float> (gm.gmptGlyphOrigin.y) -
gm.gmBlackBoxY)) ;
m_bbox.size (point_t<float> (static_cast<float> (gm.gmBlackBoxX),
static_cast<float> (gm.gmBlackBoxY))) ;
Как и в случае с FreeType, получим все необходимые геометрические размеры. Теперь мы практически готовы вызвать метод store_to_texture. Однако есть еще один момент. Если был указан 8-битный режим растеризованной картинки, то полученный bitmap содержит лишь 65 градаций серого, в то время как для корректной работы glTexSubImage2D нам нужно 255. Значит необходимо отмасштабировать полученные данные с коэффициентом 4. Мне казалось, что glPixelTransferf (GL_ALPHA_SCALE, 4.0f) даст необходимый результат, однако по каким-то причинам это не так. Поэтому мы сделаем это вручную.
for (int i = 0 ; i < size ;
static_cast<char*> (data)[i] =
static_cast<char*> (data)[i] == 0 ? 0 :
(static_cast<char*> (data)[i] - 1) * 4, ++i
) ;
И наконец приведем код метода render. Он весьма прост. Сперва мы проверяем текущую текстуру и, если необходимо, меняем ее на текстуру содержащую наш глиф. После чего устанавливаем перо и вызываем список команд созданный ранее.
float texture_glyph_t::render (float pen)
{
if (m_list == 0) return m_advance ;
GLint active_tex ;
glGetIntegerv (GL_TEXTURE_2D_BINDING_EXT, &active_tex) ;
if (active_tex != m_tex) glBindTexture (GL_TEXTURE_2D, m_tex) ;
glPushMatrix () ;
glTranslatef (pen, 0.0f, 0.0f) ;
glCallList (m_list) ;
glPopMatrix () ;
return m_advance ;
}
Шрифты: классы и реализация
Разобравшись с отдельными символами, можно приниматься за сами шрифты. Как и в случае с глифами мы будем разделять абстрактную и физическую реализацию. Как несложно догадаться, у нас опять будет два класса.
![]() | Class: ui::abstract_face_t |
Я приведу лишь код для основного метода рендеринга строки символов (остальные реализованы похожим образом).
float abstract_face_t::render (const char* s)
{
if (s == 0 || *s == 0) return 0.0f ;
m_pen = 0.0f ;
const unsigned char* ptr = reinterpret_cast<const unsigned char*> (s) ;
while (*ptr)
{
// get glyph indices for the characters
unsigned int l = *ptr ;
unsigned int r = *(ptr + 1) ;
// check whether left glyph is loaded and create it otherwise
glyph_map_t::iterator it = m_glyphs.find (l) ;
if (it == m_glyphs.end ())
{
std::pair<glyph_map_t::iterator, bool> jt ;
jt = m_glyphs.insert (std::make_pair (l, make_glyph (l))) ;
it = jt.first ;
}
// rendering and calculating advance distance (with kerning)
m_pen += kerning (l, r) + it->second->render (m_pen) ;
++ptr ;
}
return m_pen ;
}
Код достаточно прямолинеен и, полагаю, комментарии излишни. Вся основная работа сокрыта в методе render, который мы уже рассмотрели, и который отвечает за вывод глифа, а также в методах make_glyph и kerning. Посмотрим чем же занимаются эти методы. Для этого обратимся к реализации класса шрифтов хранящихся в виде текстуры.
![]() | Class: ui::texture_face_t |
float texture_face_t::kerning (unsigned int ch1, unsigned int ch2)
{
#ifdef _UI_FONTS_FREETYPE2
if (FT_HAS_KERNING (m_face) && ch1 && ch2)
{
FT_Vector kern ;
kern.x = 0 ;
int g1 = FT_Get_Char_Index (m_face, ch1) ;
int g2 = FT_Get_Char_Index (m_face, ch2) ;
FT_Error error =
FT_Get_Kerning (m_face, g1, g2, FT_KERNING_UNFITTED, &kern) ;
if (!error) return (static_cast<float> (kern.x) / 64.0f) ;
}
#endif
Код для библиотеки FreeType весьма прост, так как последняя предоставляет собственный метод получения кернинга. С Windows ситуация ненамного сложнее…
#ifdef _UI_FONTS_MSWIN
// binary search by the first character
int i = 0 ;
int j = m_kerns_num ;
int k ;
for (; i <= j ;)
{
k = (i + j) / 2 ;
DWORD dw = MAKELONG (m_kerns[k].wSecond, m_kerns[k].wFirst) ;
DWORD ch = MAKELONG (ch2, ch1) ;
if (ch == dw) break ;
if (ch > dw) i = k + 1 ; else j = k - 1 ;
}
if (i > j) return 0.0f ;
else return static_cast<float> (m_kerns[k].iKernAmount) ;
#endif
return 0.0f ;
}
Здесь мы видим уже проинициализированную таблицу, в которой методом бинарного поиска находится нужная запись. Инициализация происходит в методе init и выглядит следующим образом.
m_kerns_num = GetKerningPairs (m_hdc, 0, 0) ;
m_kerns = static_cast<LPKERNINGPAIR>
(calloc (m_kerns_num, sizeof (KERNINGPAIR))) ;
GetKerningPairs (m_hdc, m_kerns_num, m_kerns) ;
// copy over to the vector filtering thouse pairs having kerning = 0
std::vector<std::pair<DWORD, int> > v ;
for (int i = 0 ; i < m_kerns_num ; ++i)
{
if (m_kerns[i].iKernAmount == 0) continue ;
v.push_back (std::make_pair (MAKELONG (m_kerns[i].wSecond,
m_kerns[i].wFirst),
m_kerns[i].iKernAmount)) ;
}
// sort pairs
std::sort (v.begin (), v.end (), kerns_less ()) ;
// free old kernings
free (m_kerns) ;
// reallocate memory
m_kerns_num = v.size () ;
m_kerns = static_cast<LPKERNINGPAIR>
(calloc (m_kerns_num, sizeof (KERNINGPAIR))) ;
// copy things back
for (int j = 0 ; j < m_kerns_num ; ++j)
{
m_kerns[j].wFirst = HIWORD (v[j].first) ;
m_kerns[j].wSecond = LOWORD (v[j].first) ;
m_kerns[j].iKernAmount = v[j].second ;
}
Наконец мы готовы рассмотреть как же собственно создаются глифы и текстуры. Логика весьма проста — если нам встретился новый глиф, то мы должны подыскать свободное место на текстуре. Если текстура еще не создана или на текущей не осталось места, то необходимо создать новую. Размеры создаваемой текстуры зависят от максимального размера поддерживаемого драйвером/видеокартой, а также от количества еще не загруженных символов (чем меньше символов осталось загрузить, тем меньше размер создаваемой текстуры). Следующий метод рассчитывает необходимый размер.
void texture_face_t::calc_texture_size ()
{
if (!m_tex_max_size)
glGetIntegerv (GL_MAX_TEXTURE_SIZE, (GLint*) &m_tex_max_size) ;
m_tex_width =
next_power2 ((m_glyph_toload * m_glyph_width) + (PADDING * 2)) ;
if (m_tex_width > m_tex_max_size)
m_tex_width = m_tex_max_size ;
int h = static_cast<int> ((m_tex_width - (PADDING * 2)) / m_glyph_width) ;
m_tex_height = next_power2 (((m_glyph_num / h) + 1) * m_glyph_height) ;
if (m_tex_height > m_tex_max_size) m_tex_height = m_tex_max_size ;
}
Сама текстура создается методом create_texture.
GLuint texture_face_t::create_texture ()
{
calc_texture_size () ;
void* empty = calloc (m_tex_width * m_tex_height, 1) ;
GLuint tex ;
glGenTextures (1, &tex) ;
glBindTexture (GL_TEXTURE_2D, tex) ;
glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP) ;
glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP) ;
glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) ;
glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) ;
glTexImage2D (GL_TEXTURE_2D, 0, GL_ALPHA, m_tex_width, m_tex_height,
0, GL_ALPHA, GL_UNSIGNED_BYTE, empty) ;
free (empty) ;
return tex ;
}
Вот собственно и все, осталось лишь вывести строку, чем и занимается метод render. Он устанавливает режим прозрачности и вызывает базовый рендерер.
float texture_face_t::render (const char* s)
{
#ifdef _UI_FONTS_FREETYPE2
if (m_size == 0) return 0.0f ;
#endif
glPushAttrib (GL_ENABLE_BIT | GL_COLOR_BUFFER_BIT) ;
glEnable (GL_BLEND) ;
glBlendFunc (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) ; // transparency
glEnable (GL_TEXTURE_2D) ;
float advance = abstract_face_t::render (s) ;
glPopAttrib () ;
return advance ;
}
Исходный код. Тестовый пример
Исходный код доступен и распостраняется согласно MIT лицензии. В качестве тестового примера я написал весьма примитивную консоль. Для работы требуется библиотека glut и, опционально, freetype. Собранные под win32 библиотеки можно скачать отдельно, или подставить свои. Смотри readme.txt в каталоге dependencies. Русские шрифты поддерживаются Windows версией, для FreeType необходимо дополнительно поколдовать над установками charmap-ов. Ниже (черно-белый) snapshot запущенного примера.



mindon.net