OpenGL Программирование/Рендеринг Текста в Современном OpenGL 01
Вступление
правитьСкорее всего в один момент вы захотите нарисовать текст с OpenGL. Может показаться, что рисование текста входит в базовый функционал любого графического API, но в OpenGL вы не найдете таких функций. Для этого есть свои причины: текст намного сложнее, чем кажется на первый взгляд. Если вы Американец и используете шрифт фиксированной толщины, то ваша жизнь проста. Вам не нужно более 128 символов, и все они одинакового размера. Если у вас шрифт с пропорциональной шириной, то ситуация усложняется. Если вы Европеец, то 256 символов для одного языка будет достаточно, но тогда невозможно будет изобразить все остальные европейские языки. Если вы охватываете весь мир, то вам понадобиться более 65536 (16-битных) символов, и текст должен рендериться справа налево или сверху-вниз. Тогда вам придется использовать другие методы, отличающиеся от рисования символа за символом, чтобы получить нечто вразумительное. О рендеринге текста, вам возможно захочется рисовать математические уравнения, диаграммы Феймана, диаграммы шахматной доски или нотные листы. Я думаю, что вы уже убедились в том, что рендеринг текста это высокоуровневая функция, которая не имеет места в нискоуровневом API, таком как OpenGL.
Мы все еще хотим изобразить текст с OpenGL. Для нас имеется несколько вариантов:
- Рисовать текст напрямую в framebuffer с glDrawPixels() (не доступно в OpenGL ES 2.0).
- Рисовать фигуры с помощью GL_LINES.
- Рисовать фигуры с заливкой, используя GL_TRIANGLES.
- Рисовать настоящие трехмерные модели букв.
- Рисовать каждый глиф как четырехугольник с текстурой из текстурной библиотеки или глифа.
- Рисовать текст с помощью процессора на текстуру (классический рендеринг 2d текста), а затем использовать эту текстуру на четырехугольнике в трехмерном пространстве.
- Рисовать каждый глиф как четырехугольник с текстурой из векторной библиотеки глифов.
В этом уроке мы начнем с рендеринга простого (US_ASCII) текста, используя на каждый символ четырехугольник с текстурой или, в терминологии шрифтов, "глифы". Этот метод очень гибкий, и если вы будете правильно кешировать текстуры, то он окажется самым быстрым.
Качество текста будет таким же, как и качество текста в вашем браузере или текстовых редакторах.
Этот метод использует некоторые формы векторных текстур, как Альфа-Тестовое Увеличение[1]. Результаты станут четкими после нескольких преобразований
Библиотека FreeType
правитьПеред тем как отрисовать текст необходимо каким-либо образом считать шрифт и конвертировать его в формат, который используется OpenGL. Большинство операционных систем используют стандартный способ чтения шрифтов, но для этого существует множество библиотек. Существует хорошая популярная и кросс-платформенная библиотека FreeType. Она поддерживает много форматов, включая TrueType и OpenType.
С FreeType, вы можете найти символ, узнать его метрики и расположить на экране более или менее корректно; самое главное, вы получите текстуру в градациях серого для любого символа. Это весь функционал, который нам понадобиться для рисования текста в этом уроке.
FreeType даёт доступ к данным шрифта, но не скомпонует символы в строку. Это означает, что эта библиотека не будет писать строки текста и абзацы за вас. Она также не будет автоматически рисовать диакритики, ипользовать лигатуры и использовать другие сложные особенности форматирования текста. А если же вам это нужно, то используйте библиотеки форматирования, такие как Pango, чтобы нарисовать текст целиком, а не символ за символом. Это может нагружать память компьютера и замедлять его работу при динамическом изменении текста.
Использовать FreeType очень просто. Следующие 2 строчки кода должны быть добавлены в начала вашего кода, чтобы подключить заголовочные файлы:
#include <ft2build.h>
#include FT_FREETYPE_H
Перед использованием других функция FreeType необходимо инициализировать библиотеку:
FT_Library ft;
if(FT_Init_FreeType(&ft)) {
fprintf(stderr, "Could not init freetype library\n");
return 1;
}
Шрифты и глифы
правитьТермин шрифт имеет несколько значений, но обычно мы думаем о шрифтах как о "Times New Roman" или "Helvetica". Мы также различаем обычный, жирный, курсивный и другие стили, поэтому "Helvetica Bold" и "Helvetica Italic" - это разные шрифты. При использовании библиотеки FreeType необходимо указать полный путь до файла с шрифтом, которым будет изображен текст. В FreeType это называется "face" (лицо). Например, чтобы загрузить обычный шрифт FreeSans из текущей директории нужно:
FT_Face face;
if(FT_New_Face(ft, "FreeSans.ttf", 0, &face)) {
fprintf(stderr, "Could not open font\n");
return 1;
}
После загрузки face остаётся еще один параметр, который можно установить. Это размер шрифта. Чтобы установить высоту в 48 пикселей необходимо использовать:
FT_Set_Pixel_Sizes(face, 0, 48);
Face - это просто набор глифов. Глиф - это один символ, который также может быть диакритикой или лигатурой. Шрифт также может содержать по несколько глифов для одного символа для достижения его альтернативных рендерингов. (Взгляните на список особенностей идеального Linux Libertine шрифта.)
Даже символы Юникода не обязательно имеют однозначное сопоставление с глифами шрифтов. Мы забудем все эти сложности и сфокусируемся на старом и добром наборе символов ASCII. Например, давайте возьмем из шрифта глиф символа 'X':
if(FT_Load_Char(face, 'X', FT_LOAD_RENDER)) {
fprintf(stderr, "Невозможно загрузить символ 'X'\n");
return 1;
}
Функция FT_Load_Char()
заполнит информацию об этом символе в "слоте для глифов" шрифта, доступ к которому можно получить с помощью face->glyph
.
Из-за того, что мы указали FT_LOAD_RENDER
FreeType также создаст 8-битное изображение в градациях серого, которое можно получить с помощью face->glyph->bitmap
.
Каждый раз писать face->glyph
очень утомительно. А если ссылка на face->glyph
не изменяется, то проще будет создать сокращение:
FT_GlyphSlot g = face->glyph;
В уроке мы будем использовать следующую информацию:
- g->bitmap.buffer
- Указатель на серое 8-битное изображение глифа, созданное с определенным ранее размером шрифта.
- g->bitmap.width
- Ширина изображения (в пикселях).
- g->bitmap.rows
- Высота изображения (в пикселях).
- g->bitmap_left
- Горизонтальное положение относительно курсора (в пикселях).
- g->bitmap_top
- Вертикальное положение относительно основания (в пикселях).
- g->advance.x
- Как далеко по горизонтали переместиться курсор перед отрисовкой следующего символа (в 1/64 пикселя).
- g->advance.y
- Как далеко по вертикали переместиться курсор перед отрисовкой следующего символа, обычно = 0 (в 1/64 пикселя).
Зачем все эти значения? Потому что размеры глифов различаются. FreeType рендерит изображение такого размера, чтобы в него поместились все видимые части символа. Это значит, что символ '.' имеет очень маленькое изображение, а символ 'X' наоборот, большое. Именно поэтому важно знать ширину и высоту изображения символа.
Запятая ',' и апостроф '`' вероятно должны быть отрисованы одним растровым изображением, но их позиция относительно основания будет сильно отличаться. Символ 'X' начинается сразу на основании, но расширяется очень далека, когда символ 'p' не поднимается так высоко, но опускается под основание. Это обязательно нужно знать, смещение изображения происходит относительно основной линии. Более, визуальный размер символа не всегда говорит о том, как далеко смещать курсор для следующего символа. Подумайте, например, о символе пробела.
Shaders
правитьFor text rendering, we can usually settle for very basic shaders. Since text is basically two-dimensional, we could use an attribute vec2 for vertices, and another attribute vec2 for texture coordinates. But it is also possible to combine the vertex and texture coordinates into a single four-dimensional vector, and have the vertex shader split it in two:
#version 120
attribute vec4 coord;
varying vec2 texcoord;
void main(void) {
gl_Position = vec4(coord.xy, 0, 1);
texcoord = coord.zw;
}
Although this might not be directly obvious, the best way to draw text is to use a texture that contains only alpha values. The RGB color itself is set to the same value for all the pixels. Where the alpha value is 1 (opaque), the font color is drawn. Where it is 0 (transparent), the background color is drawn. Where the alpha value is between 0 and 1, the background color is allowed to mix with the font color. The fragment shader is as follows:
#version 120
varying vec2 texcoord;
uniform sampler2D tex;
uniform vec4 color;
void main(void) {
gl_FragColor = vec4(1, 1, 1, texture2D(tex, texcoord).r) * color;
}
This fragment shader allows us to render transparent text, and should be used in combination with blending:
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Rendering lines of text
правитьBefore we can start rendering text, there are still some things that need initialization. First, we will use a single texture object to render all the glyphs:
GLuint tex;
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(uniform_tex, 0);
To prevent certain artifacts when a character is not rendered exactly on pixel boundaries, we should clamp the texture at the edges, and enable linear interpolation:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
It is also very important to disable the default 4-byte alignment restrictions that OpenGL uses for uploading textures and other data. Normally you won't be affected by this restriction, as most textures have a width that is a multiple of 4, and/or use 4 bytes per pixel. The glyph images are in a 1-byte greyscale format though, and can have any possible width. To ensure there are no alignment restrictions, we have to use this line:
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
We also need to set up a vertex buffer object for our combined vertex and texture coordinates:
GLuint vbo;
glGenBuffers(1, &vbo);
glEnableVertexAttribArray(attribute_coord);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(attribute_coord, 4, GL_FLOAT, GL_FALSE, 0, 0);
We now have everything in place to render a line of text. The recipe we will use is simple. We start with a certain baseline (vertical) and cursor (horizontal) position, load the first character, upload it as a texture, draw it at the correct offset from the starting position, and then move the cursor to the next position. We repeat this for all the characters in the line.
void render_text(const char *text, float x, float y, float sx, float sy) {
const char *p;
for(p = text; *p; p++) {
if(FT_Load_Char(face, *p, FT_LOAD_RENDER))
continue;
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RED,
g->bitmap.width,
g->bitmap.rows,
0,
GL_RED,
GL_UNSIGNED_BYTE,
g->bitmap.buffer
);
float x2 = x + g->bitmap_left * sx;
float y2 = -y - g->bitmap_top * sy;
float w = g->bitmap.width * sx;
float h = g->bitmap.rows * sy;
GLfloat box[4][4] = {
{x2, -y2 , 0, 0},
{x2 + w, -y2 , 1, 0},
{x2, -y2 - h, 0, 1},
{x2 + w, -y2 - h, 1, 1},
};
glBufferData(GL_ARRAY_BUFFER, sizeof box, box, GL_DYNAMIC_DRAW);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
x += (g->advance.x/64) * sx;
y += (g->advance.y/64) * sy;
}
}
The function render_text()
takes 5 arguments: the string to render, the x and y start coordinates, and the x and y scale parameters.
The last two should be chosen such that one glyph pixel corresponds to one screen pixel.
Let's look at the display()
function which draws the whole screen:
void display() {
glClearColor(1, 1, 1, 1);
glClear(GL_COLOR_BUFFER_BIT);
GLfloat black[4] = {0, 0, 0, 1};
glUniform4fv(uniform_color, 1, black);
float sx = 2.0 / glutGet(GLUT_WINDOW_WIDTH);
float sy = 2.0 / glutGet(GLUT_WINDOW_HEIGHT);
render_text("The Quick Brown Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 50 * sy, sx, sy);
render_text("The Misaligned Fox Jumps Over The Lazy Dog",
-1 + 8.5 * sx, 1 - 100.5 * sy, sx, sy);
glutSwapBuffers();
}
We start by clearing the screen to white, and setting the font color to black. Since we are not using any transformation matrix, we can simply calculate the scaling factors by dividing 2 by the screen's width and height. The first line (a well-known pangram) is aligned exactly to pixel coordinates. The second line is deliberately misaligned by half a pixel in each direction. The difference is obvious; the second line looks more fuzzy and some ugly artifacts are visible.
You might naively think that it would be better to have textures that are twice or more times bigger than how you draw them, so that OpenGL does the anti-aliasing.
Unless you use multi-sampling or FSAA, this is not what will happen.
It will always be better to have FreeType render the font at the right size,
and render it correctly aligned.
To illustrate, let's draw the 48 point font scaled down 2 and 4 times,
and compare that to "unscaled" 24 and 12 point font sizes.
Add the following to the display()
function:
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Small Texture Scaled Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 175 * sy, sx * 0.5, sy * 0.5);
FT_Set_Pixel_Sizes(face, 0, 24);
render_text("The Small Font Sized Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 200 * sy, sx, sy);
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Tiny Texture Scaled Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 235 * sy, sx * 0.25, sy * 0.25);
FT_Set_Pixel_Sizes(face, 0, 12);
render_text("The Tiny Font Sized Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 250 * sy, sx, sy);
You should see that despite the linear texture interpolation of OpenGL, the quality of the downscaled text is worse than the unscaled text. There are several reasons for this. First is that by scaling the text, you are letting OpenGL anti-alias an already anti-aliased glyph image. Second, linear texture interpolation uses a weighted average of at most four texture elements, and is not really the same as calculating the average of a 2x2 or 4x4 region from the texture. Last but not least, the FreeType library will by default apply hinting to improve the contrast of the characters. The effect of hinting is lost when the pixels of the hinted glyph image are not mapped on-to-one to screen pixels.
Rendering colored and/or transparent text is easy, we just change the uniform color to our liking:
FT_Set_Pixel_Sizes(face, 0, 48);
render_text("The Solid Black Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 430 * sy, sx, sy);
GLfloat red[4] = {1, 0, 0, 1};
glUniform4fv(uniform_color, 1, red);
render_text("The Solid Red Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 330 * sy, sx, sy);
render_text("The Solid Red Fox Jumps Over The Lazy Dog",
-1 + 28 * sx, 1 - 450 * sy, sx, sy);
GLfloat transparent_green[4] = {0, 1, 0, 0.5};
glUniform4fv(uniform_color, 1, transparent_green);
render_text("The Transparent Green Fox Jumps Over The Lazy Dog",
-1 + 8 * sx, 1 - 380 * sy, sx, sy);
render_text("The Transparent Green Fox Jumps Over The Lazy Dog",
-1 + 18 * sx, 1 - 440 * sy, sx, sy);
Exercises:
- Try changing the background color.
- Try using GL_LUMINANCE and GL_INTENSITY instead of GL_ALPHA.
- Try changing the blending to
glBlendFunc(GL_SRC_ALPHA, GL_ZERO)
. - Try removing the call to
glPixelStorei()
. - Try different texture wrapping and interpolation modes.
- Try drawing the text
"First line\nSecond line"
. What happened? - Draw the baselines and cursors for every character.
- Add a transformation matrix and use it to rotate the text by 30 degrees.
- Use a perspective transformation matrix, and look at the text from an oblique angle.