Продолжаем серию статей о мобильном геймдеве. В этой статье я расскажу как рендерить 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 уже порезанный на блоки!

Комментарии (27)


  1. Overlordff
    21.04.2016 23:52

    Здравствуйте. Расскажите, пожалуйста, в своих статьях по подробнее об архитектуре графической части движка.


    1. Apetrus
      21.04.2016 23:53

      Ок, постараюсь посвятить отдельную статью графике.


  1. RPG
    22.04.2016 00:32
    +3

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


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

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


    1. Apetrus
      22.04.2016 10:12

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



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


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


      1. RPG
        22.04.2016 12:32

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


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


        1. Apetrus
          22.04.2016 12:47

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


  1. MrShoor
    22.04.2016 06:14
    +2

    Допустим вы добавили битмапы для арабского, японского и китайского языков. Выйдет довольно много картинок. Не спешите их все загружать! Дождитесь когда вам действительно попадется символ из этого блока и подгрузите нужную текстуру.
    С юникодным шрифтом лучше не готовить большие атласы как в вашем архиве. Если в одной строке вдруг встретятся символы из разных атласов — придется переключать текстуры, что достаточно дорого. Особенно актуально это для языков с умляутами. Посему лучше паковать только нужные символы на лету, например как то так: http://www.blackpawn.com/texts/lightmaps/ А при таком подходе вряд ли понадобится больше одной 512*512 текстуры.


  1. Apetrus
    22.04.2016 09:36

    Все верно, так и есть. В порезанном шрифте Quivira в одной текстуре помещены ascii и умляуты, что покрывает большинство европейских языков. В других текстурах лежат более специфические символы — отдельно турецкий, греческий и редкие умляуты. Отдельно идет кириллица и отдельно символы, валюты, римские цифры.


    1. MrShoor
      22.04.2016 20:30

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


      1. MrShoor
        22.04.2016 20:35

        Дополню свой же ответ: помимо кода, который менеджит N атласов нужен еще код, который парсит информацию о атласах (.fnt файлы в архиве). Если же собирать атлас в рантайме — ничего этого не нужно.


        1. Apetrus
          22.04.2016 20:53

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


          1. MrShoor
            23.04.2016 01:48

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


  1. DeXPeriX
    22.04.2016 09:52

    PNG-файлы шрифта перед использованием конвертируете в webp как остальные картинки в движке? Или ещё и libpng нужна?
    Варианты с TTF рассматривали? Почему не они? Например, stb_truetype.


    1. Apetrus
      22.04.2016 10:23

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


      1. vintage
        22.04.2016 12:58
        -1

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


        1. Apetrus
          22.04.2016 13:35
          +1

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


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


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

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


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

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


          1. vintage
            22.04.2016 15:01
            -4

            Ну то есть бенчмарки не гоняли, ясно.
            Что такое FBO?
            Не понял, какие проблемы у того же SVG с рамкой?


    1. VioletGiraffe
      25.04.2016 11:48

      STB лучше libfreetype? Или они делают не то же самое?


      1. DeXPeriX
        25.04.2016 12:55

        STB зависит только от стандартной библиотеки, соответственно очень портируемо. Лицензия STB разрешает линковаться с этими библиотеками статически даже для коммерческих проектов. STB миниатюрны, например TTF-рендер добавляет к ЕХЕ порядка 40кб при статической линковке. Из недостатков — требуется большее количество ресурсов.


        1. VioletGiraffe
          25.04.2016 15:21

          Ресурсы — это только память, или ЦП тоже? К freetype претензии по ЦП есть.


          1. DeXPeriX
            25.04.2016 22:40

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


  1. Mercury13
    22.04.2016 11:00

    Спасибо за подсказку, что можно сделать со шрифтами. Необычное дело.


  1. thatisme
    22.04.2016 14:50

    > float a=clamp((tx-params.x)*params.y, 0.0, 1.0);

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


    1. Apetrus
      22.04.2016 14:53

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


      1. thatisme
        25.04.2016 09:05

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


        1. Apetrus
          25.04.2016 09:55

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


  1. Torvald3d
    23.04.2016 13:05

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

    И не только увеличивать, но и вращать, сжимать/растягивать