воскресенье, 27 ноября 2011 г.

Текст средствами OpenGL или рисуем "Hello World!"



Изучение любого языка программирования традиционно начинается с вывода всем знакомого приветствия, что считается примером простейшей программы. К сожалению, в 3D графике эта задача далеко не тривиальная, так как стандартные средства для работы с текстом в OpenGL отсутствуют.


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

hFont = CreateFontIndirect(&logfont); 
SelectObject (hdc, hFont); 
nFontList = glGenLists(128); 
wglUseFontOutlines(hdc, 0, 128, nFontList, 0.0f, 0.5f,
  WGL_FONT_POLYGONS, agmf); 
DeleteObject(hFont);
После того как был создан набор дисплейных списков вывод текста осуществлялся таким простым способом: 
glListBase(nFontList); 
glCallLists (11, GL_UNSIGNED_BYTE, "Text Output");
Каждый символ текста трактовался как номер дисплейного списка, выводящего нужную нам букву, задавая смещение относительно начала набора символов. При этом в дисплейный список помещался вывод растровой картинки через glRasterPos + glBitmap, иногда туда помещали вывод текстурированных полигонов через glBegin/glEnd. Рисование 3D текста так же мало чем отличалось от вывода обычного текста, только параметров было больше.  
Множество примеры вывода текста таким способом можно найти на этом сайте:
http://www.opengl.org/resources/features/fontsurvey/

По традиции стоит упомянуть о GLScene, но на этом все упоминание о ней и заканчивается :)
Там для вывода текста используется подход аналогичный описанному, таким же образом генерируются дисплейный списки (разве что для кроссплатформенности написан аналог процедуры wglUseFontOutlines), куда помещается вывод текстурированного прямоугольника с сгенерированной текстурой шрифта или загруженной из файла.
Пока это все было помещено в дисплейный список все было отлично, но времена меняются и на смену OpenGL1.x-2.x пришел OpenGL 3.x, в котором все эти команды были объявлены вне закона.
Казалось бы что тут такого - точно так же рисуем по прямоугольнику на каждую букву... Но тут нужно вспомнить что glBegin/glEnd тоже устарел, а у VBO, который остался единственным способом вывести геометрию, есть существенные ограничения на вывод динамических, низкополигональных объектов, таких как текст (об этом было рассказано в предыдущих сообщениях).

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

Существует две принципиально разные методики:
1. Одна текстура на весь текст но по полигону на букву
2. Один полигон на текст, но по текстуре на слово/предложение/страницу (можно за счет атласа добиться одной текстуры на текст)

Вопрос стоит что выгоднее - сделать один бинд текстуры и отрисовать NхM-полигонов (один на букву) или сделать M-биндов текстуры и отрисовать M-полигонов (один на строку).

В обоих случаях мы используем текстуру шрифта, обычно это набор из 256 символов (16х16):

Это стандартный моноширный шрифт, но вместо него можно использовать и рисованные символы, в том числе и национальных раскладок. 

Первый способ аналогичен варианту с дисплейными списками, там решается проблема динамического изменения размера буфера VBO и динамического обновления данных в этом буфере. В случае если текст статичен, то вывод строки текста аналогичен выводу N полигонов, по штуке на каждый символ строки. Если текст динамический (фпс, лог, информация, бегущая строка и т.д.), то тут нужно написать простейший менеджер VBO-текста, который будет отвечать за обновление этого буфера. Что он должен уметь:
  • Создавать и удалять буфер VBO, резервируя место под строку;
  • Пересоздавать буфер при переполнении строки или создавать дополнительный буфер под "лишние" символы;
  • Пересчитывать координаты вершин квадов каждой буквы, чтоб они соответствовали ширине выводимого текста, и обновлять буфер VBO;
  • Заменять/устанавливать текстурные координаты каждого квада, чтоб они соответствовали нужному символу в текстуре шрифта;
  • Ну и собственно уметь выводить часть буфера VBO, соответствующего размеру строки.
В такой буфер, при незначительной доработке, можно помещать многострочный текст или отдельные строки символов. Если движок поддерживает вывод сабмешей внутри одного буфера VBO, то задача еще больше упрощается.
Минусы такого подхода - появляются лишние полигоны на сцене, и тем больше, чем больше надписей. Нужно хорошо проработать менеджер текста, чтоб они понимал что делать если большая строка, чтоб он понимал как сделать перенос строки, чтоб понимал многострочный текст, чтоб корректно пересчитывал ширину немоноширного текста, чтоб умел вставлять и удалять символы в строке, умел менять текстуры для разных шрифтов и начертаний, умел обновлять текст по указанному интервалу, чтоб понимал прокрутку текста и области видимости текста и прочее, тогда можно получить очень хороший результат и полноценный текстовый редактор.
Плюсы этого подхода - как уже было сказано, это даст возможность управлять выводом каждого символа, его положением и начертанием.


Второй подход является полной противоположностью первому. Идея этого подхода состоит в том, чтоб вывести весь текст как текстуру, одним полигоном.
Достоинства такого подхода проявляются при выводе больших блоков текста, к примеру страниц книги, хэлпа, брифинга заданий и прочего. В этом случае, для вывода страницы текста размером 40х80 символов, вместо вывода 6400 треугольников выводится лишь один большой полигон. Второй момент - так как весь текст является текстурой, то может  быть наложен на любую геометрию, что к примеру, позволит реализовать эффект перелистывания страниц книги, или написать на заборе "Здесь был Вася", или наложить текст на круглую трубу, или сделать прокрутку текста или применить шейдерный эффект и т.д. и т.п.

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


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

Второй вариант позволяет обойтись без этапа рисования на канвасе, используя лишь текстуру шрифта. В этом случае мы создаем простую текстуру, размер которой соответствует количеству символов в строке х количество строк. Так если мы хотим вывести текст "Hello World" в две строки, то нам нужно создать текстуру размером 5х2 пикселя (сравните с предыдущим вариантом, где размер текстуры мог быть 800х600 пикселей и более). В каждый пиксель этой текстуры мы помещаем код символа, по которому, в фрагментном шейдере, могут быть восстановлены полные координаты символа в текстуре, к примеру, для текстуры шрифта 16х16 символов, положение нужно символа может быть получено как:
FontY = floor(CharCode/16.0);
FontX = CharCode - FontY*16.0;
Тут естественно возможны оптимизации, к примеру можно сразу помещать в текстуру координаты символа, это вдвое увеличит объем текстуры, зато исключит этот этап из шейдера, что немного повысит производительность.

Вторая задача - правильно заполнить пространство текстуры текстом, тоесть растянуть нашу текстуру 5х2 пикселя на полигон размером 64х24 пикселя. Для этого мы должны узнать какой символ выводится в текущем пикселе, узнать какая именно часть этого символа должна там быть и по этим данным получить координаты пикселя из текстуры шрифта. Проще привести пример кода, чем описать каждое действие :)

#extension GL_ARB_texture_rectangle : enable
uniform sampler2DRect TextTexture; //текстура текста
uniform sampler2DRect CharTexture; //текстура шрифта
uniform vec4 CharRect; //количество символов, строк, размер CharTexture
varying vec2 Texcoord;

void main(void)
{
  vec2 tc = Texcoord.st*CharRect.xy;
  vec4 charcode = texture2DRect(TextTexture,tc);
  vec2 charpos = charcode.bg*CharRect.zw*16.0;
  vec2 fontPos = fract(tc)*(CharRect.zw/16.0);
  gl_FragColor = texture2DRect(CharTexture,charpos+fontPos);
}
Пример не самый оптимальный, но показывает идею. Похожая методика была изложена в статье с сайта http://lumina.sourceforge.net, но там сейчас какие-то технические трудности. Недостатки такого подхода, кроме описанных выше, заключаются в том, что текст генерится "не лету", в момент текстурирования объекта. Это дает как преимущества - изменив один лишь пиксель текстуры текста мы сразу получим новую букву, так и недостатки - тратятся ресурсы на пересчет новых координат пикселя, в результате чего производительность ниже чем при выводе текста как готовой текстуры.

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

Немного подумав, было придумано простое решение - закодировать в текстуре текста не просто код символа, но и его ширину, таким образом, если раньше текстура нашей надписи имела вид:
Hello
World
То теперь она приобрела такой вид:
HHHHHHeeeeellllllllooooo
WWWWWWWWooooorrrrrllllddddd
В результате ширина текстуры текста стала соответствовать ширине результирующей текстуры, но это избавило от необходимости определять какому символу соответствует текущий пиксель. При этом я явно указываю ширину пикселя и интервал между ними. Так как это все так же выводится один раз через FBO, то в производительности с предыдущими способами разницы нет, зато появляется возможность выводить немоноширные шрифты, такие как "Times New Roman".

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

3 комментария:

  1. Наконец-то добрался и прочёл статью. Очень познавательно, молодец.
    Вопрос: если у тебя уже реализован вывод текста, то нет ли демки на эту тему?

    ОтветитьУдалить
  2. Демки есть (скрин выше как раз с одной из них), но очень примитивные (фактически отладочные) и очень старые.
    http://file.qip.ru/file/5MjP13V7/TextSurface.html
    http://file.qip.ru/file/EcmabqK-/TextOut.html

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

    Что касается конкретно вывода текста - сейчас за это отвечает модуль uTextLayer, в котором реализован вывод текста через FBO (последний описанный вариант), в дальнейшем я планирую сделать его частью новой системы GUI, тогда вместе со статьей и напишу нормальную демку.

    ОтветитьУдалить
  3. Вообще демки это мой больной вопрос, на качественные демки не хватает ни фантазии ни времени :(

    Так что если есть какие-то наработки, которыми не жалко поделиться - с удовольствием добавлю в коллекцию.

    ОтветитьУдалить