21 авг. 2012 г.

Система рендеринга текста OpenGL 3

Работая над собственной системой GUI у меня в svn выделилась целая ветвь, связанная с работой со шрифтами и рендерингом текста. В итоге это перерасло в отдельную библиотеку (FSGL3), которую я планирую использовать в своих небольших проектах для отображения различной текстовой информации.
Целевыми платформами являются Windows и OS X последних версий.
Библиотека основана на современном GAPI OpenGL 3.2 и в ней нет ни строчки из STL.
В процессе выбора лучшего решения ее дизайна и архитектуры у меня собралось немного материала, который я решил выложить здесь.

Немного теории

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

Наиболее распространенный подход рендеринга текста основан на растеризации векторного представления символов в растр (карту битов 'bitmap') и, в дальнейшем, отображение каждого символа в виде прямоугольника ('quad'), состоящего из двух треугольников.
Этот метод хорош по производительности и дает множество путей для его реализации, и возможностей по оптимизации.
Для отображения 2D текста особенно выгодно использовать однородные координаты (clip space), которые находятся в пределах [-1, 1]. Это позволяет свести к минимуму вычисления, не используя матричные преобразования.

Для перевода вершин прямоугольника quad’а из абсолютных экранных координат (screen space) в однородные координаты (clip space) мы можем просто воспользоваться уравнениями:
где sX и sY - экранные координаты с началом (0,0) в верхнем левом углу.

Выбор способа растеризации шрифтов

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

Выбор подхода для кэширования данных

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

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

Но если нам необходимо отобразить текстовую информацию на заранее известном языке или языках, которая меняется не часто (статический текст), то выполнять поиск для каждого символа по кэшу - это ненужные накладные расходы. Гораздо лучше будет, если речь идет об Unicode, используя код символа, выполнять проверку его принадлежности только к конкретному диапазону (ASCII, CYRILIC и т.д.) т.е. да, сохранять данные сразу для нескольких заведомо используемых диапазонов символов. Так как ASCII символы нам нужны всегда, то это будет ASCII(0-127) + доп. диапазон (например: Cyrillic(1024-1279)) + ... .
При значительном объеме отображаемого текста процент использования символов будет очень большим. Кроме того, если текст вообще всегда статичен, то имеет смысл его отрендерить в текстуру и отображать за один раз.

Детали реализации

Поскольку в библиотеке используется функционал OpenGL 3.2, то нам не обойтись без шейдерных программ.
Vertex Shader :
#version 150 core

precision highp float;
// поскольку Mac OS, пока, потдержывает только OpenGL 3.2 то
// приходится явно включать расширение для работы с лэйаутами(layout)
#extension GL_ARB_explicit_attrib_location : enable

#define ATTRIB_POSITION  0
layout(location = ATTRIB_POSITION) in vec4 g_Vertex;

uniform vec2 u_Scale;
uniform vec4 u_Color;

out vec2 v_TexCoord0;
out vec4 v_Color;

void main( void )
{
    // переводим координаты из "screen-space" в "clip-space"
    gl_Position = vec4(-1 + (g_Vertex.x * u_Scale.x), 1 - (g_Vertex.y * u_Scale.y), 0, 1);
    v_Color = (u_Color / 255);
    // uv = zw
    v_TexCoord0 = g_Vertex.zw;
}
Здесь, поскольку у нас только 2D текст, для оптимизации, мы передаем позицию вершин и текстурные координаты в одном атрибуте (g_Vertex) и далее разделяем их. Коэффициент масштабирования текста передается через uniform переменную u_Scale, значение которой рассчитывается в методе SetScreenSize:
void CFontSystem::SetScreenSize(int sWidth, int sHeight)
{
    m_fontShader.Begin_Use();

    m_scaleX = 2.0 / (float)sWidth;
    m_scaleY = 2.0 / (float)sHeight;

    m_fontShader.Set_Float2(m_scaleX, m_scaleY, "u_Scale");
}
Этот метод должен вызываться каждый раз при изменении размеров окна для корректного отображения символов.
Также стоит отметить, что по возможности, большинство вычислений стоит производить именно в вершинном шейдере.

Лучший способ отобразить простой 2D текст - это использовать для него текстуру, которая содержит только значение альфа-канала (alpha = 1 (непрозрачность) - рисуется цвет текста, alpha = 0 (прозрачность) - рисуется цвет фона, когда значение alpha лежит в пределах 0-1, цвет текста смешивается с цветом фона).
Fragment Shader :
#version 150 core

uniform sampler2D textureUnit0;

// текстурные координаты и данные цвета из вершинного шейдера
in vec2 v_TexCoord0;
in vec4 v_Color;
// результирующий цвет пикселя
out vec4 FragColor;

void main( void )
{
    FragColor = vec4(1, 1, 1, texture(textureUnit0, v_TexCoord0).r ) * v_Color;
}
В этот шейдер на вход поступает "grayscale" текстура шрифта, содержащая все глифы (glyph) символов, у которой берется значение альфа-канала. Далее устанавливается цвет (rgb), полученный из вершинного шейдера.


Сборка атрибутов вершин для текста

Общий метод "сборки" атрибутов вершин для динамического и статического текста выглядит следующим образом:
inline void PointerInc(float *& pData, const float &pX, const float &pY, const float &u, const float &v)
{
    float *p = pData;
    *p++ = pX;
    *p++ = pY;
    *p++ = u;
    *p   = v;

    pData += 4;
}

template < typename T >
void CFontSystem::BuildTextVertices(const T* text)
{
    // получаем шрифт по ID
    CFont &font = *g_pFontManager.GetFontByID(m_hFont);

    // получаем высоту строки шрифта
    const int height = font.Height();

    int x = 0, y = 0;
    // текущие координаты для отображения
    int posX = m_DrawTextPos[0];
    int posY = m_DrawTextPos[1];

    m_VertexPerFrame += m_VertexCount;
    m_VertexCount = 0;
    
    // обрабатываем текстовую информацию
    for (int i = 0; i < m_TextLenght; ++i)
    {
        const T &Ch = text[i];
        
        // выставляем кэш для текущего символа
        if ( !font.AssignCacheForChar( Ch ) )
            continue;
        
        // получаем метрики символа
        const GlyphDesc_t &g = *font.GetGlyphDesc();
         
        // корректируем позицию на экране с учетом метрик символа
        x = posX + g.bitmapLeft;
        y = (posY + height) - g.bitmapTop;
        
        // переводим курсор на следующий символ
        posX += g.advanceX;
        posY += g.advanceY;
        
        // если встретили неотображаемый символ
        if (iswspace(Ch))
        {   
            // проверяем не является ли он символом новой строки
            if (Ch == '\n') {
                posX = m_DrawTextPos[0];
                posY += height;
            }
            continue;
        }

        const int w = g.bitmapWidth;
        const int h = g.bitmapHeight;

        // получаем текстурные координаты для символа из кэша
        const float *pTex = font.GetTexCoords();

        // записываем данные вершин начиная с указателя pBaseVertex
        PointerInc(pBaseVertex, x,     y,     pTex[ 0], pTex[1]);
        PointerInc(pBaseVertex, x,     y + h, pTex[ 2], pTex[3]);
        PointerInc(pBaseVertex, x + w, y,     pTex[ 4], pTex[5]);
        PointerInc(pBaseVertex, x + w, y,     pTex[ 6], pTex[7]);
        PointerInc(pBaseVertex, x,     y + h, pTex[ 8], pTex[9]);
        PointerInc(pBaseVertex, x + w, y + h, pTex[10], pTex[11]);

        m_VertexCount += 6;
    }
}
В этом методе для всех символов переданного текста мы получаем данные атрибутов вершин из кэша шрифта и записываем их по указателю pBaseVertex. Указатель pBaseVertex указывает (тавтология :)) на начало той области памяти, куда именно мы хотим записать наши данные вершин.


Вершинный буфер для статического текста

Все данные вершин статического текста хранятся в одном Vertex Buffer Object(VBO) формата (x,y,u,v .. x,y,u,v). Для возможности разделять начало данных вершин очередного текстового блока используется следующая структура:
//-----------------------------------------------------------------------------
// Информацию о статическом тексте
//-----------------------------------------------------------------------------
struct StaticTextInfo_t
{
    // первая вершина для блока текста в VBO
    unsigned short firstVertex;
    // общее количество вершин для текста
    unsigned short countVertex;
    // цвет текста
    float color[4];
};
Метод работы с вершинным буфером для статического текста:
template < typename T >
int CFontSystem::BuildStaticText(const T* text)
{
    // проверяем достаточно ли у нас памяти для переданного текста
    // 6 vertices * float4(x,y,s,t) * sizeof(float) = 96
    if ( !text || (m_StaticFreeVMem < (m_TextLenght * 96)))
        return -1;
        
    glBindBuffer(GL_ARRAY_BUFFER, m_VertexBufferId[0]);

    // получаем указатель на начало буфера
    void* pVM = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);

    if ( !pVM )
    {
        fprintf(stderr, "OpenGL Error glMapBuffer");
        return -1;
    }
    
    // получаем указатель на свободную часть буфера
    pBaseVertex = (float*)pVM + (m_CurrStaticVertex * 4);

    // получаем данные вершин для текста и записываем их по указателю
    BuildTextVertices < T > (text);

    glUnmapBuffer(GL_ARRAY_BUFFER);

    glBindBuffer(GL_ARRAY_BUFFER, 0);

    StaticTextInfo_t ti;
    
    ti.countVertex = m_VertexCount;
    ti.firstVertex = m_CurrStaticVertex;
    ti.color[0] = m_DrawTextColor[0];
    ti.color[1] = m_DrawTextColor[1];
    ti.color[2] = m_DrawTextColor[2];
    ti.color[3] = m_DrawTextColor[3];

    m_CurrStaticVertex += m_VertexCount;

    // m_VertexCount *(float4(x,y,s,t) * sizeof(float))
    m_StaticFreeVMem -= m_VertexCount * 16;

    m_StaticTextInfo.Append(ti);

    return  m_StaticTextInfo.Num();
}
Метод отображения статического текста:
void CFontSystem::PrintStaticText(const int idText)
{

    /* ... */

    // индекс в массиве
    const int index = idText - 1;

    if((numString - 1) < index)
        return;

    StaticTextInfo_t &dti = m_StaticTextInfo[index];
    
    // передаем данные о цвете
    m_fontShader.Set_Float4v(dti.color, m_UnifColor);

    glDrawArrays(GL_TRIANGLES, dti.firstVertex, dti.countVertex);

    /* ... */
}

Получение данных символов в кэше

Для проверки принадлежности символа к конкретному диапазону таблицы Unicode определим структуру, содержащую информацию о нижней и верхней границах диапазона символов и массив из этих диапазонов:
struct  UCharacterRange
{
    int lowRange;   // нижняя граница диапазона в Unicode таблице
    int upperRange; // верхняя граница диапазона в Unicode таблице
    int chOffset;   // количество символов до начала следующего диапазона
};
std::vector<UCharacterRange> m_UChRanges;
Теперь поиск для каждого символа будет сводиться только к определению - к какому из диапазонов он принадлежит:
int CFont::FindCharInCache(wchar_t wch) const
{
    for (int i = 0; i < m_iNumRanges; ++i)
    {
        // если символ попадает в диапазон шрифта
        if ((wch >= m_UChRanges[i].lowRange) && (wch <= m_UChRanges[i].upperRange))
        {
            // возвращаем его индекс в кэше
            return m_UChRanges[i].chOffset + (wch - m_UChRanges[i].lowRange);
        }
    }
    return 0;
}
Как правило, таких диапазонов достаточно не более десятка, так что прямой перебор в цикле вполне подойдет.

Использование FSGL3

1. Инициализация и задание параметров


Вызываем метод инициализации библиотеки:
FontSystem().Initialize();
Задаем масштабирование для шрифта, передавая размеры области отображения:
FontSystem().SetScreenSize( SCREEN_WIDTH, SCREEN_HEIGHT );
Загружаем шрифт и получаем его дескриптор, по которому, в дальнейшем, можно будет определять каким шрифтом отображать текст:
HFont hFreeSans_14 = FontSystem().Create_Font("YourPath/FreeSans.ttf", 14);
assert(hFreeSans_14 != INVALID_FONT);
Добавляем желаемые диапазоны символов для шрифтов, используя полученные дескрипторы:
FontSystem().AddGlyphSetToFont( hFreeSans_14, BASIC_LATIN );
FontSystem().AddGlyphSetToFont( hFreeSans_14, CYRILLIC );
// или
FontSystem().AddGlyphSetToFont( hFreeSans_14, 31, 127 );
FontSystem().AddGlyphSetToFont( hFreeSans_14, 1024, 1104 );
Порядок добавления не важен, главное чтобы присутствовал ASCII диапазон, а то получите интересный результат :)  После добавления всех интересующих диапазонов, необходимо просто вызвать:
FontSystem().BuildAllFonts();
В этом методе происходит получение метрики для всех глифов, определение высоты текстуры шрифта, создается атлас шрифта, в него копируется bitmap каждого глифа, расчитуются текстурные координаты, строится кэш.

При необходимости можно задавать ширину текстурного атласа шрифта (по умолчанию 1024):
SetFontTextureWidth( 512 );
Полученная текстура для данных диапазонов:
Для того, чтобы иметь возможность обходиться без функционала FreeType, можно сохранять кэш отдельного шрифта или шрифтов (используя их дескрипторы) на диск (сохраняются только метрики глифов + текстура):
FontSystem().DumpFontCache( hFreeSans_14, "./YourPath/" );
Для загрузки:
hFreeSans_14 = FontSystem().LoadFontCache( "./YourPath/FreeSans_14.cfnt" );
assert(hFreeSans_14 != INVALID_FONT);
После загрузки всех файлов вызываем функцию построения кэша шрифтов:
FontSystem().BuildCache();

2. Отображение текста

// Строка unicode текста
const wchar_t WText[] = L"Hello World\nили Здравствуй мир! :)";

// Выставляем дескриптор (Handle) нужного шрифта
FontSystem().BindFont( hFreeSans_14 );

// Задаем позицию для текста
FontSystem().SetTextPos(100, 25);
    
// Указываем цвет
FontSystem().SetTextColor(255, 0, 200);

// Передаем строку текста и получаем для нее ID
int id_SText = FontSystem().SetStaticWText(WText, wcslen(WText));

while( running )
{
    glClearColor(0.7, 0.7, 0.7, 1);
    glClear(GL_COLOR_BUFFER_BIT);
        
    // Биндим шейдерную программу, сбрасываем все пред. состояния и т.д.
    FontSystem().EnableStateDraw();
        
    // Отображаем статический текст
    FontSystem().PrintStaticText( id_SText );
        
    // Выставляем Handle следующего шрифта
    FontSystem().BindFont( hVerdanaB_11 );

    FontSystem().SetTextColor(0, 0, 255);
    FontSystem().SetTextPos(100, 10);
        
    std::string time;
    NowTime(time);
        
    // Отображаем динамическую строку текста
    FontSystem().PrintText(time.c_str(), time.length());
        
    FontSystem().DisableStateDraw();

    /* ... */
}

Результат:


Для любого текста можно получить его BBox. Для этого, к примеру, загружаем текст из файла:
std::wstring wstr = ReadWholeFileIntoString( "SomeFile.txt" );
int idText = FontSystem.SetStaticWText( wstr.c_str(), wstr.lenght() );
Получаем ограничивающий прямоугольник (bounding box) для этого текста:
BBox_t bbox;
FontSystem().GetWTextBBox( wstr.c_str(), wstr.lenght(), bbox );
Далее отображаем:
FontSystem().DrawOutLinedRect( bbox );
FontSystem().PrintStaticText( idText );

TODO list:
Добавление эффектов blur, outline, scanline, dropshadow, свободное масштабирование текста с применением технологии distance fields, поддержка кернинга.
По оптимизации попробую добавить instancing или geometry shader для генерации геометрии квада.

Технические детали
Поскольку библиотека не использует C runtime library, то не имеет значения, какая именно версия проекта собирается (single-threaded, multi-threaded или multi-threaded DLL). Это позволяет собирать все проекты, использующие библиотеку с такими же версиями C runtime.

При сборке для Mac OS, FreeType упорно не хотела линковаться статически, в итоге  нормально собралось со следующими опциями:
./configure --prefix=/ft2 CFLAGS="-Os -arch x86_64" --disable-shared --without-bzip2

Links:

Комментариев нет:

Отправить комментарий