Иногда все, что требуется — быстро вывести какой‑то текст в Renderpass. Традиционно отрисовка текста требует отрендерить все возможные символы шрифта в атлас, затем привязать полученный атлас как текстуру и затем отрендерить каждый глиф, рисуя треугольники, каждый из которых должен соотноситься с нужным глифом из текстуры атласа шрифта.
Так делает imgui, равно как и все, кто использует stb_truetype. Сам процесс приятно напоминает процесс наборного производства на физических станках.
Причудливо, правильно, но в то же время напряжно.
Если нам нужно просто вывести какое‑то сообщение для дебага? Нет ли какого‑либо более простого метода?
В данной статье я опишу метод бестекстурной отрисовки дебаг‑текста. Вдобавок, отрисовка будет производиться в один вызов draw.
Шрифт: Pixels Sans Texture
Как бы нам избавиться от использования текстур? Нам бы пришлось хранить атлас шрифта или что‑то подобное напрямую в шейдере фрагмента. Мы, конечно же, не можем хранить битмапы в шейдерах, но мы можем хранить целочисленные константы, а они, если достаточно прищуриться, вполне себе набор битов. Может, сделаем вид, что целое число это и есть битмап?
Мы можем отрисовать это на экране, используя шейдер фрагмента GLSL, соотнеся позицию xy
фрагмента с битом в «битмапе». Если бит имеет значение, мы отрисовываем цвет переднего плана, если нет — цвет заднего плана.
uint bitmap = 0x42;
vec4 col_fg = vec4(1,1,1,1);
vec4 col_bg = vec4(0,0,0,1);
// vec2 uv is the normalized texture coordinate for the fragment
// with the origin top-left
uint which_bit = 7 - min(7,floor(uv.x * 8));
out_color = mix(col_bg, col_fg, (bitmap >> which_bit) & 1);
При таком подходе один байт даст нам одну линию пикселей. Для отрисовки красивых глифов нам потребуется больше байт. Взяв 16 байт (что даст нам 16 линий) на глиф, мы получим холст размером 8 на 16 пикселей. Который аккуратно помещается в один uvec4 — встроенный в GLSL тип.
16 байт на глиф — достаточно небольшой размера, таким образом для кодирования 96 доступных для печати символов ASCII нам потребуется 1536 байт памяти шейдера.
Откуда мы получаем битмапы?
К нашему счастью, описанный выше способ кодирования это, по сути, устоявшийся формат PSF1, плюс‑минус несколько байт заголовков. Так что мы можем извлечь пиксели глифов из любого шрифта терминала PSF1, открыв его в hex‑редакторе, например ImHex, пропустив заголовок (4 байта) и первую секцию не печатных символов (512 байт) и экспортировав данные следующий 96 глифов (1536 байт) с помощью «Copy as → C Array».
Это даст нам аккуратно форматированный массив символов, который мы можем легко преобразовать в массив uint и затем сгруппировать в uvec4. Нужно помнить, что объединение символов зеркально меняет порядок байтов uint, но мы всегда можем отразить его обратно.
По завершении наша таблица данных битмапов шрифта в шейдере фрагмента будет выглядеть следующим образом:
const uvec4 font_data[96] = {
{ 0x00000000, 0x00000000, 0x00000000, 0x00000000 }, // 0x1e: SPACE
{ 0x00000000, 0x08080808, 0x08080800, 0x08080000 }, // 0x21: '!'
{ 0x00002222, 0x22220000, 0x00000000, 0x00000000 }, // 0x22: '\'
{ 0x00000000, 0x1212127E, 0x24247E48, 0x48480000 }, // 0x23: '#'
// ... etc ...
{ 0x00000808, 0x08080808, 0x08080808, 0x08080808 }, // 0x7C: '|'
{ 0x00000030, 0x08081010, 0x08040810, 0x10080830 }, // 0x7D: '}'
{ 0x00000031, 0x49460000, 0x00000000, 0x00000000 }, // 0x7E: '~'
{ 0xFC1B26EF, 0xC8E04320, 0x8958625E, 0x79BAEE7E }, // 0x7F: BACKSPACE
};
Я называю это таблицей, так как массив font_data
теперь хранит битмапы для глифов 96 символов и индексируется по значению ASCII (минус 0x20
). Соответственно, эта таблица хранит выводимые символы ASCII с 0x20
SPACE до 0x7F
BACKSPACE включительно, в сниппете кода выше показано лишь 8 для экономии места.
Все это ради того, чтобы не привязывать текстуру при отрисовке текста. Но как же нам отрисовать сам текст?
Один вызов отрисовки — это всё
Мы будем использовать один instanced‑вызов отрисовки.
При instanced‑отрисовке нам не нужно постоянно отправлять инструкции отрисовки, поскольку мы можем закодировать логику в данные экземпляра. Один вызов отрисовки содержит все необходимое при условии, что мы используем два потока атрибутов. Первый — на каждую отрисовку — содержит необходимую для отрисовки обычного прямоугольника информацию. Второй — на каждый экземпляр — содержит два элемента, которые меняются от экзепляра к экземпляру в прямоугольнике: отступы, которые укажут, где на экране рисовать прямоугольник, а также, конечно же, текст для отрисовки.
Для отступов мы можем использовать по одному числу с плавающей запятой на координаты x и y, что оставляет два числа с плавающей запятой неиспользованными. При желании мы можем добавить параметр масштаба шрифта — места для этого достаточно.
С текстом похожая ситуация — минимальный тип данных атрибута вершины обычно имеет ширину в 32 бита, так что лучше всего будет упаковывать текст в группы по 4 символа. При таком подходе нам нужно, чтобы длина строки делилась на 4. Для остальных строк потребуется «набивать» их нульбайтами (\0
). Удобное совпадение — нульбайты также используются для обозначения конца C‑строки.
Данные экземпляра выглядят следующим образом:
struct word_data {
float pos_and_scale[ 3 ]; // xy position + scale
uint32_t word; // four characters that we want to print
};
Ответственность за разделение сообщения на блоки по 4 символа, преобразование в uint32_t
и сохранение в структуре word_data
вместе с отступами позиции ложится на плечи приложения. После заполнения word_data
мы можем добавить его в массив, хранящий необходимые для вызова отрисовки текста данные. Когда мы готовы к отрисовке, мы можем привязать массив в качестве привязки экземпляра к пайплайну отрисовки дебаг-текста и отрисовать его за один instanced-вызов.
Более интересные вещи происходят в шейдерах вершин и фрагментов нашего пайплайна.
Vertex Shader
Шейдер вершин возвращает 3 значения.
Во‑первых он записывает данные в gl_Position
для размещения вершин треугольников на экране. Это происходит в координатах NDC «экранного пространства». Мы рассчитываем отступ для каждой из вершин, используя атрибут pos_and_scale
каждого из экземпляров.
Во‑вторых возвращается слово для отрисовки — мы просто передаем uint
атрибута шейдеру фрагмента, используя квалификатор flat
для избежания интерполяции.
И наконец, шейдер вершин генерирует координаты текстур с помощью gl_VertexIndex
. Сам процесс достаточно хитро реализован:
12 >> gl_VertexIndex & 1
даст последовательность0, 0, 1, 1
,9 >> gl_VertexIndex & 1
даст последовательность1, 0, 0, 1
,
Что в свою очередь создает последовательность uv‑координат, не прибегая к ветвлению.
#version 450 core
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable
// Inputs
// Uniforms - Push Constants
layout (push_constant) uniform Params
{
vec2 u_resolution; // screen canvas resolution in physical pixels
};
// Input Attributes
layout (location = 0) in vec3 pos; // "vanilla" vertex position attribute - given in pixels
layout (location = 1) in uint word; // per-instance: four chars
layout (location = 2) in vec3 word_pos; // per-instance: where to place the word in screen space
layout (location = 3) in vec4 col_fg; // per-instance: foreground colour
layout (location = 4) in vec4 col_bg; // per-instance: background colour
// Vertex Outputs
struct per_word_data {
uint msg;
vec4 fg_colour;
vec4 bg_colour;
};
out gl_PerVertex { vec4 gl_Position; };
layout (location = 0) out vec2 outTexCoord;
layout (location = 1) flat out per_word_data outMsg;
void main()
{
outMsg.msg = word;
outMsg.fg_colour = col_fg;
outMsg.bg_colour = col_bg;
vec2 scale_factor = vec2(1.,2.)/(u_resolution);
outTexCoord = vec2((12 >> gl_VertexIndex) &1, (9 >> gl_VertexIndex ) &1);
vec4 position = vec4(0,0,0,1);
position.xy = vec2(-1, -1) + (pos.xy * word_pos.z + word_pos.xy) * scale_factor;
gl_Position = position;
}
Визуализировав вывод вершинного шейдера сейчас мы получим что‑то подобное:
Fragment Shader
Шейдеру фрагмента для отрисовки текста необходимо передать три значения, два из которых он получает на этапе работы шейдера вершин:
Интерполированная uv‑координата фрагмента
uv
Символ, который мы хотим отрисовать —
in_word
Массив данных шрифта —
font_data
Для отрисовки глифа каждый фрагмент должен соотнести uv‑координаты с нужным битом в битмапе глифа. Если бит задан, фрагмент отрисовывается цветом переднего плана, если нет — заднего.
Соотнесение реализовано следующим образом:
Сначала мы должны соотнести uv‑координаты с координатами пикселей слова. На счастье, и та, и другая системы координат начинаются в левом верхнем углу.
Мы знаем, что uv‑координаты это нормализованные числа с плавающей запятой от vec2(0.f,0.f)
до vec2(1.f,1.f)
, в то время как координаты пикселей — целые числа от uvec2(0,0)
до uvec2(7,15)
.
Также нам необходимо понять, какой из четырех символов в слове необходимо отрисовывать.
const uint WORD_LEN = 4; // 4 characters in a word
// quantize uv coordinate to discrete steps
uvec2 word_pixel_coord = uvec2(floor(uv.xy * vec2( 8 * WORD_LEN, 16)));
// limit pixel coord range to uvec2(0..31, 0..15)
word_pixel_coord = min(uvec2( 8 * WORD_LEN -1, 16 -1), word_pixel_coord);
// Find which of the four characters in the word this fragment falls onto
uint printable_character = in_word >> (WORD_LEN - (word_pixel_coord.x / 8));
// Map fragment coordinate to pixel coordinate inside character bitmap
uvec2 glyph_pixel_coord = uvec2(word_pixel_coord.x % 8, word_pixel_coord.y);
Вы же помните, что для того, чтобы отрисовать символ, нам нужно найти его в таблице битмапов шрифта, в которой необходимо найти и проверить нужный бит, основываясь на uv‑координате фрагмента. Как вы можете заметить, в первом примере GLSL выше нас интересовала только координата .x
. Давайте теперь сфокусируемся на .y
, чтобы мы могли отрисовать больше линий пикселей, найдя правильную линию.
Будем действовать пошагово. Сначала мы получаем битмап символа из font_data
в виде uvec4
. Затем мы используем glyph_pixel_coord.y
, чтобы выбрать правильный из 4х uint
«ов, составляющих глиф. Это дает нам 4 строки пикселей.
// First, map character ASCII code to an index offset into font_data table.
// The first character in the font_data table is 0x20, SPACE.
offset = printable_character - 0x20;
// Then get the bitmap for this glyph
uvec4 character_bitmap = font_data[offset];
// Find the uint that contains one of the four lines that
// are touched by our pixel coordinate
uint four_lines = character_bitmap[glyph_pixel_coord.y / 4];
Теперь, когда у нас есть uint
, отвечающий за 4 строки, нам нужно выбрать нужную строку из него.
Заметьте, что из‑за того, что мы просто объединили chars
в uint
после использования ImHex для получения байтов битмапов из файла шрифта, линии хранятся в обратном порядке. Однако оставим все как есть, поскольку объединять символы, скопированные из ImHex просто, по сравнению с ручным изменением порядка байтов в редакторе.
uint current_line = (four_lines >> (8*(3-(glyph_pixel_coord.y)%4))) & 0xff;
И наконец, нам нужно выбрать правильный бит в битмапе. Обратите внимание, что мы используем 7-
, так как старший бит в байте имеет наибольший индекс. Чтобы преобразовать это в систему координат справа налево, нам снова потребуется индексировать в обратную сторону.
uint current_pixel = (current_line >> (7-glyph_pixel_coord.x)) & 0x01;
Теперь мы можем использовать полученный пиксель для закрашивания фрагмента — если пиксель задан в битмапе, мы закрашиваем его цветом переднего плана, если нет — цветом заднего плана:
vec3 color = mix(background_colour, foreground_colour, current_pixel);
Но что насчет символов, используемых для “набивки”, если длина текста не делится на 4? Мы определяем их в шейдере фрагмента: При попытке рендеринга подобного символа мы абсолютно ничего не делаем, даже не рисуем фон. Это реализуется проверкой printable_character
и вызовом discard
, если значение символа равно \0
.
Визуальное резюме
Выбираем нужный символ из четверки.
Рассчитываем отступ в
font_data
, используя номер в таблице ASCII.Получаем
uvec4
с битмапом для глифа изfont_data
.По y‑координате выбираем
uint
, содержащий четыре линии, среди который находится нужная нам.По y‑координате выбираем нужную линию.
По x‑координате выбираем нужный бит.
Полная реализация и больше исходного кода
Реализацию описанной выше техники можно найти в исходном коде le_print_debug_print_text, новом модуле Island, позволяющим легко выводит дебаг‑текст на экран. Там также есть такие приятности, как обработка текста и кэширование, но описывать их было бы слишком долго для этой статьи.
Используя эту технику, можно вызвать почти в любой части проекта Island следующий код:
char const msg_2[] = { 70, 111, 108, 107, 115, '!', 0 };
le::DebugPrint( "That's all, %s", msg_2 );
И получить следующий результат:
iShrimp
На Shadertoy, кажется, встречался способ рисования пиксельной графики с помощью формулы Таппера - при определённых значениях аргумента её можно заставить отображать всё что угодно.