Урок №45. Рендеринг текста в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 18 Дек 2020  | 

 3125

В один прекрасный момент нашего необычайно интересного путешествия по миру графического программирования OpenGL, вам наверняка захочется отобразить какой-нибудь текст. На первый взгляд может показаться, что получить на экране компьютера самую обычную строку текста, используя низкоуровневое API (по типу OpenGL), — очень простая задача. На самом деле это не так. Ведь только представьте, вам придется позаботиться о рендеринге более 128 различных символов, которые имеют разную ширину, высоту и отступ. И что делать, если вдобавок вы захотите использовать специальные символы, а также символы музыкальных нот или математических выражений? А как насчет рендеринга текста сверху вниз?

Поскольку в OpenGL нет поддержки средств отображения текстовых данных, то нам предстоит самим решать вопрос вывода текста на экран. Помимо этого, также не существует и графических примитивов для представления текстовых символов, поэтому мы должны проявить творческий подход и придумать их сами. Вот некоторые идеи того, как это можно реализовать: рисовать буквенные фигуры с помощью GL_LINES, создавать 3D-меши букв или попробовать визуализировать текстуры символов на плоских прямоугольниках в 3D-пространстве.

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

Растровые шрифты

В прежние времена рендеринг текста включал в себя выбор шрифта (или самостоятельное его создание), который вы хотели бы использовать для своего приложения, и извлечение из этого шрифта всех соответствующих символов, чтобы поместить их в одну большую текстуру. Такая текстура называется растровым шрифтом, и содержит в своих строго заданных областях все символы, которые нам могут потребоваться. Символы, входящие в состав такого шрифта, называют глифами (англ. «glyphs»). Каждый глиф расположен в определенной области связанной со шрифтом текстуры. Всякий раз, когда вы хотите визуализировать символ, вы производите выборку соответствующего глифа и визуализируете на плоский прямоугольник нужную секцию растрового шрифта.

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

Такой подход имеет ряд преимуществ и недостатков. Его относительно легко реализовать, поскольку растровые шрифты уже предварительно растеризованы. Однако он не отличается особой гибкостью. Если вы хотите использовать другой шрифт, вам нужно будет полностью перекомпилировать новый растровый шрифт, и при этом ваша программа будет ограничена только одним разрешением шрифта; масштабирование быстро приведет к появлению некрасивых пиксельных краев. Кроме того, шрифт может содержать лишь ограниченный набор символов, а значит, об отображении расширенных или Unicode-символов часто не может быть и речи.

Описываемый подход был довольно популярен в свое время, так как он быстр и работает на любой платформе, но на сегодняшний день существуют более гибкие методы. Одним из них является загрузка TrueType-шрифтов с помощью библиотеки FreeType.

Библиотека FreeType


Библиотека FreeType — это библиотека, которая используется для растеризации шрифтов и операций над ними. Она используется в таких продуктах, как: macOS, Java, PlayStation, Linux и Android. Что делает FreeType особенно привлекательным, так это то, что она способна загружать TrueType-шрифты.

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

Библиотека FreeType доступна на сайте разработчиков. Вы можете скомпилировать библиотеку самостоятельно или использовать одну из уже предварительно скомпилированных версий под различные целевые платформы. Обязательно прилинкуйте к вашему проекту файл библиотеки freetype.lib и убедитесь, что ваш компилятор знает, где найти соответствующие заголовочные файлы.

Затем нужно подключить в проекте необходимые заголовочные файлы:

Предупреждение: Из-за некоторых особенностей разработки FreeType (по крайней мере, на момент написания этой статьи), нельзя помещать заголовочные файлы данной библиотеки в дополнительные каталоги; они должны быть расположены в корне вашей папки с заголовочными файлами. Если вы попробуете подключить FreeType директивой по типу #include <FreeType/ft2build.h>, то у вас, скорее всего, произойдет конфликт заголовочных файлов.

FreeType загружает TrueType-шрифты и для каждого глифа генерирует растровое изображение, вычисляя несколько различных метрик глифа. Для создания текстуры мы можем взять полученные растровые изображения и, используя загруженные метрики, расположить каждый символ глифа соответствующим образом.

Для загрузки шрифта всё, что нам нужно сделать, — это инициализировать библиотеку FreeType и загрузить шрифт в face (так называют данный объект сами разработчики FreeType). Ниже представлен фрагмент кода, с помощью которого мы загружаем файл TrueType-шрифта arial.ttf, который был скопирован из каталога Windows/Fonts:

При возникновении ошибки, каждая из этих FreeType-функций возвращает ненулевое целое число.

После того, как мы загрузили face, необходимо определить размер шрифта, который мы хотели бы извлечь из face:

Вышеописанная функция задает параметры ширины и высоты шрифта. Установка ширины в 0 позволяет face динамически вычислять ширину шрифта в зависимости от заданной высоты.

Объект face библиотеки FreeType содержит коллекцию глифов. Вызвав FT_Load_Char, мы можем установить один из этих глифов в качестве активного глифа. Ниже мы пробуем загрузить глиф символа 'X':

Задавая FT_LOAD_RENDER в качестве одного из флагов загрузки, мы говорим FreeType создать для нас 8-битное растровое изображение в формате оттенков серого, к которому мы можем получить доступ через face->glyph->bitmap.

Однако глифы, которые мы загружаем с помощью FreeType, не имеют единого размера (как это было в случае с растровыми шрифтами). Например, растровое изображение символа точки '.' имеет гораздо меньшие размеры, чем растровое изображение символа 'X'. Размеры растрового изображения, генерируемого FreeType, достаточно велики для того, чтобы вместить символ. По этой причине FreeType также загружает несколько метрик, которые определяют, насколько большим должен быть каждый символ и как правильно его расположить. Ниже представлено изображение, на котором демонстрируются метрики, рассчитываемые библиотекой FreeType для каждого символа:

Каждый из глифов находится на т.н. baselineбазовой линии или линии шрифта (на картинке она представлена жирной горизонтальной стрелкой), при этом одни глифы находятся на этой базовой линии (например, 'X'), а другие — немного ниже её (например, 'g' или 'p'). Рассматриваемые метрики определяют точные смещения для правильного расположения каждого глифа на базовой линии, насколько большим должен быть каждый глиф и на сколько пикселей нам нужно сдвинуться, чтобы отобразить следующий глиф. Далее приведен небольшой список метрик, которые нам понадобятся:

   width — ширина (в пикселях) растрового изображения глифа, доступ к которому осуществляется через face->glyph->bitmap.width;

   height — высота (в пикселях) растрового изображения глифа, доступ к которому осуществляется через face->glyph->bitmap.rows;

   bearingX — горизонтальное смещение (в пикселях) изображения относительно точки отсчета. Доступ осуществляется через face->glyph->bitmap_left;

   bearingY — вертикальное смещение (в пикселях) изображения относительно базовой линии. Доступ осуществляется через face->glyph->bitmap_top;

   advance — горизонтальное смещение (в 1/64 пикселях) от начала координат до начала следующего глифа. Доступ осуществляется через face->glyph->advance.x.

Мы могли бы загружать символ глифа, получать его метрики и генерировать текстуру каждый раз, когда хотим отобразить символ на экране, но делать это каждый кадр было бы неэффективно. Лучше сохранить сгенерированные данные где-нибудь в приложении и запрашивать их всякий раз, когда мы хотим визуализировать символ. Определим для этого удобную структуру, которую поместим в контейнер std::map:

На данном уроке мы постараемся упростить задачу, ограничившись 255 символами из набора Windows-1251. Для каждого символа мы создаем текстуру и храним соответствующие данные в структуре Character, которую добавляем к переменной Characters. Таким образом, все данные, необходимые для визуализации каждого символа, сохраняются для последующего использования:

В цикле for мы проходимся по всем 255 символам набора Windows-1251 и извлекаем соответствующие им глифы. Для каждого символа мы создаем текстуру, задаем её параметры и сохраняем метрики. Здесь интересно отметить, что мы используем GL_RED в качестве аргументов internalFormat и format текстуры. Растровое изображение, полученное из глифа, представляет собой 8-битное изображение, заданное градациями серого, где каждый цвет задается одним байтом. По этой причине мы хотели бы сохранить каждый байт растрового буфера в качестве значения одного цвета текстуры. Мы достигаем этого, создавая текстуру, у которой каждый байт соответствует красному компоненту цвета текстуры (первый байт её цветового вектора). Если мы используем один байт для представления цветов текстуры, то нужно позаботиться о соответствующем ограничении OpenGL:

OpenGL требует, чтобы все текстуры имели 4-байтовое выравнивание, т.е. чтобы размер всегда был кратен 4 байтам. Обычно это не является проблемой, так как большинство текстур имеют ширину, кратную 4 и/или используют 4 байта на пиксель, но поскольку теперь мы используем только один байт на пиксель, то текстура может иметь любую возможную ширину. Установив GL_UNPACK_ALIGNMENT равным 1, мы гарантируем отсутствие проблем с выравниванием (которые, к слову говоря, могут привести к ошибкам сегментации).

Убедитесь, чтобы процедура освобождения ранее задействованных ресурсов происходила только после того, как вы закончите работу с глифами:

Шейдеры

Для визуализации глифов мы будем использовать следующий вершинный шейдер:

Мы объединяем данные координат позиции символа и текстуры в один vec4. Вершинный шейдер умножает координаты на матрицу проекции и пересылает координаты текстуры во фрагментный шейдер:

Фрагментный шейдер принимает две uniform-переменные: одна — монохромное растровое изображение глифа, а другая — это uniform-переменная для настройки конечного цвета текста. Сначала мы производим выборку значения цвета из растровой текстуры. Поскольку данные текстуры хранятся только в её красном компоненте, мы используем r-компоненту текстуры для сэмплирования альфа-значения. В результате изменения альфа-значения выходного цвета, результирующий пиксель будет прозрачным для всех фоновых цветов глифа и непрозрачным для настоящих пикселей символа. Мы также умножаем RGB-цвета на uniform-переменную textColor, чтобы изменить цвет текста.

Однако для этого нам нужно включить смешивание:

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

Мы устанавливаем нижнюю часть матрицы проекции как 0.0f, а верхнюю — равную высоте окна. В результате получается, что y-координата будет принимать значения от нижней части экрана (0.0f) до верхней части экрана (600.0f). Это означает, что точка (0.0, 0.0) теперь соответствует нижнему левому углу экрана.

Последнее — это создание VBO и VAO для рендеринга прямоугольников. Теперь, при инициализации VBO, мы резервируем достаточно памяти, чтобы позже обновить память VBO при рендеринге символов:

Для 2D-прямоугольника требуется 6 вершин с 4-мя переменными типа float на каждую вершину. Поскольку мы будем обновлять содержимое памяти VBO довольно часто, то выделим эту память с помощью GL_DYNAMIC_DRAW.

Рендеринг строки текста

Чтобы визуализировать символ, мы берем соответствующую структуру Character из контейнера Characters и вычисляем размеры прямоугольника, используя метрики символа. С помощью вычисленных размеров прямоугольника мы динамически генерируем набор из 6 вершин, которые используем для обновления содержимого памяти, управляемой VBO, с помощью glBufferSubData().

Далее, создаем функцию под названием RenderText(), которая отображает строку символов:

Код функции является относительно простым: сначала мы вычисляем координаты исходного положения прямоугольника (переменные xpos и ypos) и его размеры (переменные w и h), а затем генерируем набор из 6 вершин, чтобы сформировать 2D-прямоугольник; обратите внимание, что мы умножили каждую метрику на scale. Затем мы обновляем содержимое VBO и визуализируем прямоугольник.

Однако следующая строка кода требует дополнительного внимания:

Некоторые символы (например, 'p' или 'g') отображаются немного ниже базовой линии, поэтому прямоугольник также должен быть расположен немного ниже значения y-компоненты переменной RenderText. Точная величина, на которую нам нужно сместить ypos ниже базовой линии, может быть вычислена из метрик глифа:

Чтобы вычислить это смещение, нам нужно определить расстояние, на которое глиф опускается ниже базовой линии; на вышеприведенном изображении это расстояние обозначено красной стрелкой. Как вы можете видеть из метрик глифа, мы можем вычислить длину этого вектора, вычитая bearingY из высоты (растрового изображения) глифа. Это значение равно 0.0 для символов, которые лежат на базовой линии (например, 'X'), и больше нуля для символов, которые находятся немного ниже базовой линии (например, 'g' или 'j').

Если вы всё сделали правильно, то теперь вы можете успешно визуализировать строки текста, используя следующий код:

В результате, у вас должно получиться что-то похожее на следующее изображение:

  GitHub / Урок №45. Рендеринг текста в OpenGL — Исходный код

Чтобы дать вам представление о том, как мы рассчитали вершины квадрата, мы можем отключить смешивание, чтобы увидеть, как выглядят фактические визуализированные прямоугольники:

На вышеприведенном изображении вы можете ясно видеть, что большинство прямоугольников лежит на (воображаемой) линии шрифта, в то время как прямоугольники, соответствующие глифам типа Р или ( — сдвинуты вниз.

Что дальше?

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

Еще одна проблема с растровыми изображениями FreeType-шрифтов заключается в том, что текстуры глифов хранятся с фиксированным размером шрифта, поэтому существенное увеличение их масштаба может привести к появлению неровных краев. Кроме того, вращение, примененное к глифам, приведет к тому, что они будут выглядеть размытыми. Это можно смягчить, если вместо сохранения фактических растеризованных цветов пикселей сохранить расстояние до ближайшего контура глифа для каждого пикселя. Этот метод носит название signed distance field fonts, и Valve опубликовала статью несколько лет назад об их реализации данного метода, который удивительно хорошо работает для приложений 3D-рендеринга.

Дополнительные ресуры


   70+ Best Free Fonts for Designers: обобщенный список большой группы шрифтов для использования в вашем проекте (для личного или коммерческого использования).

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (8 оценок, среднее: 5,00 из 5)
Загрузка...

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *