Рендеринг UTF-8 текста с помощью SDF шрифта

  • Tutorial

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



Содержание


Часть 1. Мобильный кроссплатформенный движок
Часть 2. Рендеринг UTF-8 текста с помощью SDF шрифта
Часть 3. Рендеринг капли с прозрачностью и отражениями




SDF (Signed Distance Field) — это изображение из оттенков серого, сгенерированное из контрастного черно-белого изображения, в котором уровень серого цвета означает дистанцию до ближайшей контрастной границы. Звучит запутанно, но на самом деле все очень просто.


Сам SDF шрифт выглядит так:



Давайте возьмем это изображение и изменим его уровни (levels) в фотошопе или любом другом графическом редакторе.



Выглядит уже лучше! У нас есть четкий шрифт со сглаживанием на краях.
Так же мы можем получить жирное или тонкое начертание. А вот получить Italic увы не получится.



Самый главный плюс SDF — это возможность увеличивать шрифт без заметных артефактов.



Более подробно о технике SDF рекомендую почитать тут.


Как создавать SDF шрифт?


Прежде всего нужно создать самый обычный черно-белый bitmap шрифт. Сделать это можно в старом добром BMFont или в UBFG.


Для хорошего результата генерируйте шрифт размером 400pt, без сглаживания, с отступами 45x45x45x45 и размером картинки 4096x4096. Merging при таких размерах советую отключить т.к. скорее всего UBGF зависнет.


Экспортируем картинку в PNG без прозрачности, а формат описания желательно выбрать BMFont (для пущей совместимости).



Далее нам понадобится ImageMagick и следующая команда:


convert font.png -filter Jinc ( +clone -negate -morphology Distance Euclidean -level 50%,-50% ) -morphology Distance Euclidean -compose Plus -composite -level 43%,57% -resize 12.5% font.png

На выходе мы получим картинку 512x512, которая даст нам в итоге весьма хороший результат.
Из файла с описанием нам нужно будет вытащить символы в unicode и их положение/размер (не забудьте разделить координаты на 8 т.к. мы уменьшали картинку). Какие именно символы надо экспортировать, я расскажу чуть ниже в разделе про UTF-8.


Минутку, в UBFG ведь есть встроенный Distance Field!
Да, есть. Но результат получается заметно хуже. Возможно в обновлениях авторы UBFG это поправят.


Шейдеры для рендеринга текста


Вертексный шейдер для вывода каждой буквы, символ за символом:


#ifdef DEFPRECISION
precision mediump float;
#endif

attribute mediump vec2 Vertex;

uniform highp mat4 MVP;
uniform mediump vec2 cords[4];

varying mediump vec2 outTexCord;

void main(){   
    outTexCord=Vertex*cords[3]+cords[2];
    gl_Position = MVP * vec4(Vertex*cords[1]+cords[0], 0.0, 1.0);
}

DEFPRECISION нужен для OpenGL ES.
В cords[1] и cords[0] передаем положение и скейл символа на экране.
А в cords[2] и cords[3] — координаты символа на текстуре шрифта.


Фрагментный шейдер


#ifdef DEFPRECISION
precision mediump float;
#endif

varying mediump vec2 outTexCord;
uniform lowp sampler2D tex0;
uniform mediump vec4 color;
uniform mediump vec2 params;

void main(void){
    float tx=texture2D(tex0, outTexCord).r;
    float a=min((tx-params.x)*params.y, 1.0);
    gl_FragColor=vec4(color.rgb,a*color.a);
}

В color передаем цвет и прозрачность буквы.
А через params регулируем толщину и сглаживание краев шрифта.


Если можно регулировать толщину шрифта, то значит можно выводить и рамку!
Фрагментный шейдер текста с рамкой:


#ifdef DEFPRECISION
precision mediump float;
#endif

varying mediump vec2 outTexCord;
uniform lowp sampler2D tex0;
uniform mediump vec4 color;
uniform mediump vec4 params;
uniform mediump vec3 borderColor;

void main(void){
    float tx=texture2D(tex0, outTexCord).r;
    float b=min((tx-params.z)*params.w, 1.0);
    float a=clamp((tx-params.x)*params.y, 0.0, 1.0);
    gl_FragColor=vec4(borderColor+(color.rgb-borderColor)*a, b*color.a);
}

Дополнительно мы передаем толщину, сглаживание в params.zw и цвет рамки в borderColor.
Должен получиться вот такой результат:



Чтобы получить красивые края как при маленьких, так и при больших размерах текста, надо подобрать разные параметры контраста/сглаживания (params) для маленького шрифта и для большого. Затем интерполировать их по текущему размеру.


На мой взгляд, для маленьких размеров хорошо подходит:


  • более жирное начертание
  • более сглаженные края
  • бордюр минимальный и размытый, чтобы не рябил

Для большого размера:


  • более тонкое начертание шрифта
  • края очень резкие
  • бордюр больше и резче

Иконки



В современном дизайне довольно популярными стали плоские иконки. Бесплатных векторных иконок полным полно. Все что нам нужно сделать — собрать черно-белый текстурный атлас из нужных иконок и точно так же прогнать его через ImageMagick!


В итоге мы можем хранить иконки в довольно низком разрешении, но получать хороший результат при скейле и вращении иконок!


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


UTF-8


В современных проектах никто уже не использует однобайтные кодировки. Все перешли на UTF-8, wchar, unicode. Мне например удобно работать со строками в UTF-8 char*.
UTF-8 легко раскодируется в unicode и отлично стыкуется с Java/String и NSString.


Ф-ция преобразования UTF-8 в Unicode:


static inline unsigned int UTF2Unicode(const unsigned char *txt, unsigned int &i){
    unsigned int a=txt[i++];
    if((a&0x80)==0)return a;
    if((a&0xE0)==0xC0){
        a=(a&0x1F)<<6;
        a|=txt[i++]&0x3F;
    }else if((a&0xF0)==0xE0){
        a=(a&0xF)<<12;
        a|=(txt[i++]&0x3F)<<6;
        a|=txt[i++]&0x3F;
    }else if((a&0xF8)==0xF0){
        a=(a&0x7)<<18;
        a|=(a&0x3F)<<12;
        a|=(txt[i++]&0x3F)<<6;
        a|=txt[i++]&0x3F;
    }
    return a;
}

Бонус! Изменяем реестр unicode символа.
static inline unsigned int uppercase(unsigned int a){
    if(a>=97 && a<=122)return a-32;
    if(a>=224 && a<=223)return a-32;
    if(a>=1072 && a<=1103)return a-32;
    if(a>=1104 && a<=1119)return a-80;
    if((a%2)!=0){
        if(a>=256 && a<=424)return a-1;
        if(a>=433 && a<=445)return a-1;
        if(a>=452 && a<=476)return a-1;
        if(a>=478 && a<=495)return a-1;
        if(a>=504 && a<=569)return a-1;
        if(a>=1120 && a<=1279)return a-1;
    }
    return a;
}

static inline unsigned int lowercase(unsigned int a){
    if(a>=65 && a<=90)return a+32;
    if(a>=192 && a<=223)return a+32;
    if(a>=1040 && a<=1071)return a+32;
    if(a>=1024 && a<=1039)return a+80;
    if((a%2)==0){
        if(a>=256 && a<=424)return a+1;
        if(a>=433 && a<=445)return a+1;
        if(a>=452 && a<=476)return a+1;
        if(a>=478 && a<=495)return a+1;
        if(a>=504 && a<=569)return a+1;
        if(a>=1120 && a<=1279)return a+1;
    }
    return a;
}

Блоки UTF-8


В большинстве шрифтов, особенно креативных, есть только ascii и latin. Как же быть, если нам нужны, например, символы валют? Особенно актуально для in-app платежей, где какие только валюты не попадаются. Предлагаю следующую схему, которая очень хорошо себя зарекомендовала:



Как узнать какие символы есть в шрифте?


Тут на помощь нам приходит странная штука от Adobe — тада! — пустой шрифт!
Его можно использовать в CSS: font-family: Roboto, Adobe Blank;
Именно так получены таблички из картинки выше. Остается только скопировать нужные куски символов и вставить их в UBFG. В итоге мы получим несколько картинок 512х512, где каждая будет содержать столько символов, сколько в нее влезет.


Что за универсальный шрифт?


Шрифтов содержащих большинство Unicode символов не так уж и много. Я остановился на Quivira. По крайней мере с символами валют у него все хорошо.


Допустим вы добавили битмапы для арабского, японского и китайского языков. Выйдет довольно много картинок. Не спешите их все загружать! Дождитесь когда вам действительно попадется символ из этого блока и подгрузите нужную текстуру.


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


Я обещал плюшки!
Ловите готовый SDF шрифт Quivira уже порезанный на блоки!

Поделиться публикацией
Комментарии 38
    0
    Здравствуйте. Расскажите, пожалуйста, в своих статьях по подробнее об архитектуре графической части движка.
      0
      Ок, постараюсь посвятить отдельную статью графике.
      +3

      Не стоит также игнорировать экспорт кернинг-пар — в экзотических шрифтах отсутствие кернинга ой как заметно.


      Минутку, в UBFG ведь есть встроенный Distance Field!
      Да, есть. Но результат получается заметно хуже. Возможно в обновлениях авторы UBFG это поправят.

      Странно. Раньше в алгоритме были ошибки и приближенное вычисление SDF, но в последнем коммите я пытался поправить SDF-генератор и вроде бы он не уступает по качеству BruteForce (см. тест, PSNR > 70 дБ).

        0

        Взял последнюю доступную версию для мака 1.1. Вот сравнение:



        Видно, что в UBFG радиус размытия больше. И это может быть хорошо, если нужна широкая рамка. Но для ровных краев не подходит. Конечно можно скейлить в 8 раз и в UBFG. Но сделать SDF в UBGF для шрифта размером 400pt занимает очень много времени! ImageMagick пока что лидер по скорости и качеству создания SDF.


        Вот какой ф-ционал пригодился бы — возможность задавать резервный шрифт, если символ отсутствует в оригинальном. Сейчас подставляется Arial, если не ошибаюсь? Сперва копал исходники, чтобы самому это исправить, не получилось.

          0

          А, всё правильно. Он в отдельной ветке SDF, плюс я не успел новый класс внутрь fontrender-а засунуть.


          Резервный шрифт устанавливает операционная система. Надо либо делать свой движок рендеринга ttf, что довольно хлопотно, либо поискать как в ОС поменять резервный Arial на какой-то другой (в linux, например, за это fontconfig отвечает).

            0

            В любом случае, спасибо за UBFG! Так же хочу порекомендовать вашу утилиту Cheetah-Texture-Packer для запаковки текстурных атласов. Именно на ее основе я добавил в сборщик проекта автогенерацию атласов из папки.

        +2
        Допустим вы добавили битмапы для арабского, японского и китайского языков. Выйдет довольно много картинок. Не спешите их все загружать! Дождитесь когда вам действительно попадется символ из этого блока и подгрузите нужную текстуру.
        С юникодным шрифтом лучше не готовить большие атласы как в вашем архиве. Если в одной строке вдруг встретятся символы из разных атласов — придется переключать текстуры, что достаточно дорого. Особенно актуально это для языков с умляутами. Посему лучше паковать только нужные символы на лету, например как то так: http://www.blackpawn.com/texts/lightmaps/ А при таком подходе вряд ли понадобится больше одной 512*512 текстуры.
          0
          Все верно, так и есть. В порезанном шрифте Quivira в одной текстуре помещены ascii и умляуты, что покрывает большинство европейских языков. В других текстурах лежат более специфические символы — отдельно турецкий, греческий и редкие умляуты. Отдельно идет кириллица и отдельно символы, валюты, римские цифры.
            0
            Я так понимаю это был ответ мне. Ну та же кириллица например может спокойно встречаться с латинскими буквами и цифрами. Не вижу ни одной причины создавать N атласов, и в рантайме подбирать нужный атлас в зависимости от символа, потому как код, отвечающий за это будет не меньше, чем код, упаковывающий атлас в рантайме. Сходите по моей ссылке, посмотрите. Там реально мало кода.
              0
              Дополню свой же ответ: помимо кода, который менеджит N атласов нужен еще код, который парсит информацию о атласах (.fnt файлы в архиве). Если же собирать атлас в рантайме — ничего этого не нужно.
                0

                Код упаковки хороший, спасибо! Плз поправьте меня, если я не верно понял. SDF шрифт мы все равно делаем заранее (в том числе читаем .fnt файлы) и видимо как-то его все таки режем или здоровенной одной текстурой храним? А потом в рантайме, когда нам попадаются новые символы, добавляем их в кеш-текстуру? Или же заранее смотрим какие у нас будут строки и строим кеш-текстуру? А потом точно так же рендерим текст, но уже из кеш-текстуры. Верно?

                  0
                  Ну основная идея — получать битмап одного символа, и упаковывать его в атлас. Как получать этот битмап — зависит от того что у вас за проект. Если это игра — то скорее всего лучше заранее пререндерить, потому что необходимый шрифт может оказаться не установленным у конечного пользователя. А в случае скажем с текстовым редактором наоборт, нужно использовать шрифты, которые установленны у пользователя и делать все в реалтайм.
                  Если мы пререндерим символы — то вариантов несколько. Самая простая реализация — создать для каждого символа по файлу, и названия файлам дать скажем хекс кодом символа. Тогда никакого fnt файла не нужно.
                  Недостатки:
                  1. Все символы должны быть одной высоты чтобы base line совпадала.
                  2. Нет кернинговых пар.
                  3. Куча мелких файлов на винте.
                  Это все можно легко устранить, для Win например если воспользоваться Compound File. Там можно сохранить и позицию baseline в глифе, и кернинговые пары, и сами битмапы. Ну или fnt сохранять, как сейчас, но как по мне, так проще разобраться с Compound файлами. Получите монолитный блок (один файл), что гарантирует что ничего не перепутается, парсить не надо, доступ кешируемый и т.п.
            0
            PNG-файлы шрифта перед использованием конвертируете в webp как остальные картинки в движке? Или ещё и libpng нужна?
            Варианты с TTF рассматривали? Почему не они? Например, stb_truetype.
              0

              Да, конвертирую в WEBP без потери качества "-lossless -q 100".
              Остановился на SDF, потому что альтернативы:
              а) генерируют битмап шрифт на лету. А это время, ресурсы, зачем? И все равно для хорошего качества атлас должен быть здоровый. Да и бордюров нет.
              б) рендерят векторный шрифт. Тут у меня много вопросов к скорости. И опять так бордюры :)
              За stb спасибо! Смотрю у них много других полезных библиотек есть.

                –1
                Можно подумать ресайз и контраст бесплатно обходятся. У вас есть замеры скорости или чисто по ощущениям?
                  +1

                  Ок, прикинем по кол-ву операций и их примерной тяжести:


                  а) Генерация надписи в FBO и ее последующий вывод без ресайза и контраста.


                  • Часто надпись динамическая. Например выводим очки юзеру "Score: 142" и цифры бегут. Каждый фрейм изменять FBO будет явно медленнее. К тому же на этом FBO тоже надо шрифт как-то выводить. Замкнутый круг.
                  • Если же шрифт битмапный статический, то ресайз у него занимает ровно столько же времени, сколько и в SDF. Однако хороший статический битмап будет по размеру раза в два больше (считаем что для хорошего качества на retina нам нужен скейл 2х). А вывод бОльшей текстуры однозначно будет медленнее, к тому же тут будет даунскейл в 2 раза, а в SDF текстура будет примерно совпадать.
                  • Итак разница только во фрагментном шейдере. Добавили clamp, умножение и сложение. На FPS это никак не повлияло ни на одном из девайсов.

                  б) Векторный шрифт. Если нам не нужна рамка, то может быть и быстрее будет.


                  • Однако сложно сказать сколько нужно полигонов для хорошего результата.
                  • К тому же мы никак не контролируем сглаживание краев и на маленьких размерах получим жуткую рябь.
                  • Для рамки же придется выводить символ два раза, но! хорошую рамку мы все равно не получим т.к. скейл!=рамка.

                  Раньше я использовал 2х битмапы, контур добавлял заранее в фотошопе, а разные сочетания цвета шрифта и рамки хранил в разных текстурах. Вот это было глупо и неудобно. Пока не попробовал SDF. При всем старании, минусов в этой технике не вижу.

                    –4
                    Ну то есть бенчмарки не гоняли, ясно.
                    Что такое FBO?
                    Не понял, какие проблемы у того же SVG с рамкой?
                0
                STB лучше libfreetype? Или они делают не то же самое?
                  0
                  STB зависит только от стандартной библиотеки, соответственно очень портируемо. Лицензия STB разрешает линковаться с этими библиотеками статически даже для коммерческих проектов. STB миниатюрны, например TTF-рендер добавляет к ЕХЕ порядка 40кб при статической линковке. Из недостатков — требуется большее количество ресурсов.
                    0
                    Ресурсы — это только память, или ЦП тоже? К freetype претензии по ЦП есть.
                      0
                      Если найдёте где-нибудь нормальное сравнение, или сделаете сами — не забудьте поделиться с сообществом. У меня такого нет…
                      По ЦП: обычно же шрифт рендерится в буферную текстуру, которая жрёт память, но очень быстро накладывается на экран. При этом значительные затраты ЦП на рендеринг шрифт есть только при загрузке игры, и происходит относительно быстро даже на мобильных девайсах.
                0
                Спасибо за подсказку, что можно сделать со шрифтами. Необычное дело.
                  0
                  > float a=clamp((tx-params.x)*params.y, 0.0, 1.0);

                  Разработчик игры должен сам контролировать это в игровом коде. А если значения x и y в params находятся в диапазоне 0.0 — 1.0, значит clamp не нужен вообще. Я не знаю, как реализован clamp в железе, будет ли это условие или хитрая математика, но лучше уменьшать количество расчетов на каждый пиксель.
                    0

                    params.y — это контраст и значение будет около 10-20. Этот результат мы потом умножаем на прозрачность текста. Вот для чего нужен clamp, иначе выставив прозрачность 50% мы ее не получим. Можно заменить на min(1.0, ...).

                      0
                      Да, тогда лучше заменить на min — одно условие, вместо двух у clamp.
                        0

                        Поправил в статье. Однако в шейдере с рамкой один clamp все же нужен, чтобы был цвет рамки/переход/цвет текста.

                          0
                          C чего вы взяли что clamp 0..1 AKA saturate это вообще какие-то инструкции?
                          Обычно это просто модификатор. ALU делает saturate (также как и минус или масштабирование) при записи результата в регистр после какой-либо операции.

                          Например в описании ISA PowerVR 6 написано

                          3.4. Modifiers

                          The arguments to instructions, depending on the instruction, can normally carry a number of modifiers
                          (Table 3), such as absolute value, negation, floor and others. These “additional” operations incur no
                          additional cost.

                          Table 3. Instruction modifiers
                          .sat Instruction f16/f32: Clamping to [0.f, 1.f]
                            0
                            Потому, что saturate это не clamp. И clamp'у все равно, что им обрезают. Но я подозреваю, что в железе clamp может быть реализован без каких-либо ветвлений. Но тут ключевое слово «может быть».
                              0
                              >> Потому, что saturate это не clamp

                              saturate() это clamp 0..1

                              https://msdn.microsoft.com/ru-ru/library/windows/desktop/bb509645(v=vs.85).aspx

                              ret saturate(x)

                              The x parameter, clamped within the range of 0 to 1.

                                0
                                А чеснок пахнет колбасой. Я правильно уловил ход вашей мысли?
                                  0
                                  Вам beeruser пытается донести, что saturate ( он же clamp(.., 0,1) ) — будет быстрее min, который вы предложили автору.
                                  Я даже на всякий случай проверил. Код:
                                  float uVal;
                                  float4 main(): COLOR0
                                  {
                                      return clamp(uVal, 0.0, 1.0);
                                  }
                                  

                                  HLSL компилятор (с O3) компилирует в одну инструкцию:
                                  mov_sat oC0, c0.x

                                  потому что _sat — это модификатор, и на многих железках он ничего не стоит (вам выше процитировали).

                                  А код:
                                  float uVal;
                                  float4 main(): COLOR0
                                  {
                                      return min(uVal, 1.);
                                  }
                                  

                                  тот же HLSL компилятор (c O3) комиплирует вот в такую телегу:
                                  def c1, -1, 1, 0, 0
                                  mov r0.x, c0.x
                                  add r0.y, r0.x, c1.x
                                  cmp oC0, r0.y, c1.y, r0.x

                                  Что значительно тяжелее.

                                  А автору статьи в этом месте лучше всего было заюзать saturate вместо clamp-а, чтобы не надятся на оптимизатор, да и код красивее станет.
                                    0
                                    Вот теперь мысль понятна. Спасибо за ликбез.
                      0
                      Самый главный плюс SDF — это возможность увеличивать шрифт без заметных артефактов.

                      И не только увеличивать, но и вращать, сжимать/растягивать
                        0
                        Кто-нибудь может пояснить, по какому принципу заполняется матрица
                        uniform highp mat4 MVP;
                          0
                          MVP — Model*View*Projection матрица. Вам надо гуглить что такое матрица вида, матрица проекции и матрица модели.
                        0
                        float tx=texture2D(tex0, outTexCord).r;

                        Почему в этой строке участвует именно r-компонента, а не a?
                          0

                          Потому что текстура черно-белая в формате GL_LUMINANCE

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое