В декабре 2023 года я выпустил 175 Pixel Font Megapack, за которым вскоре последовали 42 отдельных пака для каждого из семейства шрифтов. Я создал собственный тулчейн для генерации, тестирования и развёртывания этих шрифтов ... на Rust! В посте я расскажу об этом процессе.

Pixel Font Megapack можно приобрести на itch.io!

Что было до мегапака

Для начала поговорим о старых паках.

Первый пак

В 2016 году, когда я только начинал работать над Ikenfell, у меня было туго с деньгами. На то время я уже давно занимался созданием шрифтов для своих игр, в том числе и двух для Ikenfell, поэтому я улучшил их, создал ещё несколько и начал продавать пак из 12 шрифтов на itch.io.

Preview of pixel font pack 1

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

Второй пак

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

Preview of pixel font pack 2

Кроме того, я уделил гораздо больше времени и усилий рекламе. Я написал множеству инди-разработчиков, создававших игры в стиле пиксель-арта, и спросил, можно ли использовать скриншоты из их игр, но заменить шрифты моими, чтобы показать, как они выглядят в реальных проектах. Эти картинки я дополнил несколькими придуманными макетами игр.

Screenshots and mockups of various games using the pixel fonts
Не представляете, сколько людей спрашивало меня, где можно найти эти фальшивые игры...

Последствия

Пак был очень популярен и принёс много денег, в чём, мне кажется, помогли эти макеты игр. Шрифты использовали в сотнях инди-игр и даже в больших играх, например, в Cadence of Hyrule Nintendo, созданной разработчиком Brace Yourself Games.

Screenshot from Cadence of Hyrule
Появление моих шрифтов в игре Zelda, пусть даже и в спин-оффе, сильно меня мотивировало.

Одна из самых любимых мной игр — это Get in the Car, Loser от разработчика Love Conquers All Games, где использовали множество моих шрифтов.

Screenshot of characters talking from Get in the Car, Loser!
У этой игры просто уморительный сюжет.
У этой игры просто уморительный сюжет.

Я был очень доволен этими результатами, поэтому возврат к пиксельным шрифтам был всего лишь делом времени.

Цели, поставленные мной при создании мегапака

После выпуска Ikenfell я долгое время приходил в себя от выгорания, и у меня даже не было сил работать над другой игрой. В то время я продолжал учиться и совершенствовать свои навыки программирования на Rust. Кроме того, я продолжал изучать пиксельные шрифты, намереваясь рано или поздно собрать ещё один пак.

Но я знал, что должен буду превзойти предыдущий пак, поэтому готовился к этому.

Больше и лучше

В первом паке было 12 шрифтов, а во втором — 40. На сей раз я был настроен создать пак... из сотни шрифтов! К тому же я хотел повысить их качество. Мне хотелось вариативности — шрифтов, которые можно использовать для научно-фантастических игр, фэнтези-игр, хорроров, симуляторов фермы, миленьких игр и так далее.

Я стремился создать всеобъемлющий пак пиксельных шрифтов.

Поддержка большего количества языков

Больше всего просьб (а иногда и жалоб) по предыдущим пакам было связано с тем, что они поддерживали только символы ASCII. Об этом прямым текстом говорилось на странице, но, к сожалению, многие люди не знали, что такое ASCII, и ассоциировали его с текстом без форматирования.

В шрифтах не было символов с диакритикой, поэтому поддержка языков в играх оказывалась очень ограниченной. На этот раз я решил расширить набор символов латиницы до полной поддержки EFIGS (English, French, Italian, German, Spanish, то есть английского, французского, итальянского, немецкого и испанского). Символы с диакритикой иногда встречаются и в английском (например, «touché!»), так что это улучшит поддержку и нелокализованных игр.

Image displaying all the supported characters in my pixel fonts
Окончательный набор символов, на котором я остановился.

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

Вариации стилей

Я заметил, что при работе с моим паком из 40 шрифтов разработчики часто используют разнообразные шрифты. В играх обычно много разных UI, а хорошему UI требуется визуальная иерархия. Если весь текст будет иметь одинаковый размер и вес, сложно что-то выделить или сделать менее заметным, из-за чего снижается читаемость. Ещё сложнее ситуация с пиксельными шрифтами, потому что они не векторные и не масштабируются плавно.

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

Cover image for the Virtue font, displaying its style variations
Обложка для шрифта Virtue со всеми вариациями стилей

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

Полнота кернинга

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

Например, если у меня есть всего три символа, A B C, то они могут иметь следующие потенциальные пары кернинга: AAABACBABBBCCACB и  CC. То есть всего девять! На самом деле, можно вычислить количество пар, просто возведя в квадрат количество поддерживаемых вами символов.

В моих новых шрифтах будут поддерживаться 176 символов, то есть мне может понадобиться ввести аж целых 176² = 37976 пар кернинга... чего, конечно, я делать не буду. Поскольку в этот раз я (внимание, спойлер) собирался написать собственный инструмент для генерации шрифтов, было решено частично автоматизировать этот процесс, охватив подавляющее большинство пар кернинга, и выполнять ручной ввод только тогда, когда алгоритма недостаточно.

Улучшенный контроль качества

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

Кроме того, я не мог удобным образом проверить общее качество шрифтов. Поэтому на этот раз я хотел придумать способ генерации больших примеров текстов шрифта и визуализаций пар кернинга, чтобы в процессе работы над шрифтами я мог предварительно их просматривать, с лёгкостью находить проблемы и сразу проверять, исправлены ли они.

Упрощение развёртывания и поддержки

Ускорение и автоматизация контроля кернинга и качества привели логичному выводу: мне следует автоматизировать и весь процесс загрузки/развёртывания. Моя цель заключалась в том, чтобы добавление улучшений в шрифты, устранение ошибок и создание новых шрифтов в будущем было бы простым, надёжным и автоматическим процессом с минимальным количеством ошибок.

Повышаем стандарты

Хотя мои предыдущие шрифты получили хорошие отзывы и обладали приемлемым качеством, чтобы создать нечто гораздо более впечатляющее, мне нужно было совершенствоваться.

Изучение дизайна шрифтов

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

В этом мне сильно помогла книга Ричарда Пулина Design School: Type.

Cover and preview pages of "Design School: Type" by Richard Poulin
Обложка и примеры страниц из «Design School: Type» Ричарда Пулина

Это было отличное руководство для начинающих, а также исчерпывающий словарь по огромному объёму терминологии и стандартов, существующих в дизайне шрифтов.

Фотореференсы

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

Twelve reference photos of various signs, books, and other lettering

Я не копировал их, а скорее исследовал. Изучал формы, то, как некоторые из букв свисают ниже базовой линии, то, что некоторые буквы очень тонкие, а другие широкие.

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

PIFO: мой инструмент для работы со шрифтами

Чтобы достичь своих амбициозных целей, а ещё потому, что я хотел и никто не мог меня остановить, я написал на Rust собственную программу для создания пиксельных шрифтов: pifo!

Как она работает

Для дизайна шрифтов я люблю использовать свои обычные инструменты пиксель-арта. При создании шрифта я создаю лист тайлов в PNG и файл конфигурации с указанием метрик и параметров, управляющих поведением инструмента. Затем эти файлы можно отправить в pifo:

pifo --all --output "Faraway" --input "Faraway*"

Далее эта программа берёт изображение, разрезает его на отдельные тайлы глифов, генерирует для них контуры, автоматически вычисляет между ними пары кернинга, а затем объединяет их все в шрифт и экспортирует в файл TTF. Кроме того, она экспортирует шрифт во множество форматов для игровых движков.

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

Использованные крейты

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

  • clap для парсинга аргументов командной строки

  • image для декодирования и кодирования изображений

  • rayon для параллелизации

  • serde для сериализации данных

  • glyph-names сопоставляет символы с именами глифов

  • ab-glyph для загрузки и растеризации шрифтов

  • crunch для прямоугольной упаковки

Этап 1: создание листов шрифтов

Каждый лист шрифта имеет лист тайлов и файл конфигурации. Всё это выглядит так:

A grid of characters for a pixel font
version = "1.0"          # версия шрифта
baseline = 14            # базовая линия от верхней границы тайла
line_gap = 0             # зазор между вертикальными линиями текста
spacing = 3              # ширина символа пробела
metrics = []             # задавать метрики для глифов вручную
auto_kerning = true      # включение автоматического кернинга
auto_kerning_min = -1    # никогда не выполнять кернинг дальше, чем на 1 пиксель влево
manual_kerning = []      # ручной кернинг отдельных пар глифов
skip_kerning_left = ""   # не выполнять кернинг, если эти глифы являются левой парой
skip_kerning_right = ""  # не выполнять кернинг, если эти глифы являются правой парой

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

Этап 2: преобразование глифов в контуры

После подготовки листа начинается преобразование в контуры. Это самая сложная часть процесса, поэтому я разберу здесь все этапы алгоритма.

Глифы TrueType создаются из одного или нескольких контуров, то есть, по сути, из замкнутых фигур, состоящих из кривых. Например, буква i будет состоять из одного контура для точки и второго контура для остальной части фигуры.

Так как мы работаем с пиксельными шрифтами, нам нужно создавать контур для каждой соединённой группы (или «кластера») пикселей. Возьмём для примера строчную t: нам нужно, чтобы алгоритм преобразовал её из пикселей в два контура A и B следующим образом:

Неважно, где начинается и заканчивается контур, ведь он замкнут.

Собираем пиксели

Начав с изображения глифа, мы сначала должны выделить все пиксели на битовом изображении, потому что с ними мы и будем работать. Я обнаружил, что проще не работать с изображением напрямую, а писать алгоритмы, работая с коллекциями структур Point, выглядящих вот так:

// у них есть derive, методы и конструкторы,
// но я не буду здесь перечислять их все
#[derive]
pub struct Point {
    pub x: i16,
    pub y: i16,
}

impl Point {
    fn new(x: i16, y: i16) -> Self {
        Self { x, y }
    }

    fn right(&self) -> Self {
        Self::new(self.x + 1, self.y)
    }

    fn left(&self) -> Self {
        Self::new(self.x - 1, self.y)
    }

    fn below(&self) -> Self {
        Self::new(self.x, self.y + 1)
    }

    fn above(&self) -> Self {
        Self::new(self.x, self.y - 1)
    }

    fn left_edge(&self) -> (Point, Point) {
        (Self::new(self.x, self.y + 1), Self::new(self.x, self.y))
    }

    fn right_edge(&self) -> (Point, Point) {
        (
            Self::new(self.x + 1, self.y),
            Self::new(self.x + 1, self.y + 1),
        )
    }

    fn top_edge(&self) -> (Point, Point) {
        (Self::new(self.x, self.y), Self::new(self.x + 1, self.y))
    }

    fn bottom_edge(&self) -> (Point, Point) {
        (
            Self::new(self.x + 1, self.y + 1),
            Self::new(self.x, self.y + 1),
        )
    }

    pub fn sign(self) -> Self {
        Self::new(self.x.signum(), self.y.signum())
    }
}

Имея RgbaImage, мы можем собрать все белые пиксели в HashSet:

const WHITE: Rgba<u8> = Rgba([255; 4]);
let pixels: HashSet<Point> = img
    .enumerate_pixels()
    .filter(|(_, _, p)| *p == &WHITE)
    .map(|(x, y, _)| Point::new(x as i16, y as i16))
    .collect();

Выявление кластеров

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

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

Это делается при помощи такого алгоритма:

// генерируем список "кластеров" связанных пикселей
let mut clusters: Vec<HashSet<Point>> = Vec::new();
{
    // для нахождения кластеров мы выполняем заливку пикселей изображения
    // и удаляем их, пока ни одного не останется
    let mut pixels = pixels.clone();
    let mut to_process = Vec::new();

    // пока остаются пиксели
    while let Some(&p) = pixels.iter().next() {
        // добавляем пиксель в список для обработки
        to_process.push(p);

        // создаём новый кластер
        let mut cluster = HashSet::new();

        // пока есть пиксели для обработки
        while let Some(p) = to_process.pop() {
            // удаляем пиксель и добавляем его в кластер
            pixels.remove(&p);
            cluster.insert(p);

            // создаём очередь обработки для всех соседних пикселей,
            // которые пока не были обработаны
            to_process.extend(p
                .adjacent()
                .into_iter()
                .filter(|p| pixels.contains(p))
            );
        }

        clusters.push(cluster);
    }
}

Создание списка рёбер

Теперь у нас есть кластеры, каждый из которых обозначает одну «фигуру», составленную из контуров. Теперь нам нужно найти границу каждого из них, потому что именно из неё мы будем создавать контуры.

Для этого мы сначала создаём список всех крайних рёбер всех пикселей; под «крайним» имеется в виду, что с этой стороны нет соседних пикселей. В примере с одним кластером это выглядит так:

Вот код для создания такого списка рёбер:

let mut edges: Vec<(Point, Point)> = cluster
    .iter()
    .map(|p| {
        [
            (!cluster.contains(&p.above())).then(|| p.top_edge()),
            (!cluster.contains(&p.right())).then(|| p.right_edge()),
            (!cluster.contains(&p.below())).then(|| p.bottom_edge()),
            (!cluster.contains(&p.left())).then(|| p.left_edge()),
        ]
    })
    .flatten()
    .flatten()
    .collect();

Объединение рёбер в цепочки

Следующий этап будет непростым. Нам нужно взять этот список рёбер и создать путь из них, соединив их вместе. Если представить, что каждое ребро — это кортеж точек (tail, head), то мы можем написать алгоритм, связывающий все head с совпадающими с ними tail для создания непрерывного маршрута.

// каждый кластер будет генерировать один или несколько контуров
let mut contours: Vec<Vec<Point>> = Vec::new();

// если у нас остались рёбра, начинаем новый путь
while let Some((a, b)) = edges.pop() {
    // контур начинается с первого ребра
    let mut contour = vec![a, b];
    let mut end = b;
    let mut i = 0;

    while i < edges.len() {
        // проверяем часть после последнего ребра в цепочке
        if let Some((j, (_, b))) = edges[i..]
            .iter()
            .cloned()
            .enumerate()
            .find(|(_, (a, _))| a == &end)
        {
            edges.remove(i + j);
            contour.push(b);
            end = b;
            i += j;
            if i >= edges.len() {
                i -= edges.len();
            }
            continue;
        }

        // проверяем часть до последнего ребра в цепочке
        if let Some((j, (_, b))) = edges[..i]
            .iter()
            .cloned()
            .enumerate()
            .find(|(_, (a, _))| a == &end)
        {
            edges.remove(j);
            contour.push(b);
            end = b;
            i = j;
            if i >= edges.len() {
                i -= edges.len();
            }
            continue;
        }

        break;
    }

    // конечная точка совпадает с начальной
    contour.pop();

    // добавляем её в список контура
    contours.push(contour);
}

Так мы создадим из кластера следующий контур:

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

Также стоит отметить, что когда у нас встречаются кластеры с отверстиями, движение снаружи кластера выполняется по часовой стрелке, а в отверстиях — против часовой стрелки. Это происходит потому, что рёбра передаются в порядке по часовой стрелке, что очень удобно, ведь именно так и работают контуры TTF! Когда нам нужно описать отверстие в глифе, мы создаём внутренний контур и делаем его направление противоположным направлению внешнего контура.

Удаление неугловых точек

Такой алгоритм работает, но мы можем его немного оптимизировать. Когда несколько рёбер образуют линию, то они нам не нужны. Если удалить все неугловые точки, то можно уменьшить размер файла и увеличить скорость растеризации.

Мы делаем это после объединения в цепочку и непосредственно перед добавлением контура в список:

// если в последовательности точек a→b→c нормаль a→b равна
// нормали b→c, то можно удалить b и объединить a→c напрямую
let mut i = 1;
while i <= contour.len() {
    let a = contour[i - 1];
    let b = contour[i % contour.len()];
    let c = contour[(i + 1) % contour.len()];
    if (b - a).sign() == (c - b).sign() {
        contour.remove(i % contour.len());
    } else {
        i += 1;
    }
}

// и вот теперь можно добавить контур в список
contours.push(contour);

Этап 3: таблицы кернинга

После того, как мы закончили с контурами, для создания файла TTF нам остаётся только добавить таблицы кернинга. Эта таблица сообщает шрифтам, когда допускается сдвигать символы влево, чтобы слова были более плотными.

Если сдвинуть пары Va и lt влево всего на один пиксель, то слово будет выглядеть гораздо красивее.
Если сдвинуть пары Va и lt влево всего на один пиксель, то слово будет выглядеть гораздо красивее.

Ручной кернинг и Alts

Кернинг можно вручную задавать в файле TOML шрифта. То есть для пар кернинга из приведённого выше примера мы можем сделать следующее:

manual_kerning = [
    { left = "V", right = "a", kern = -1 },
    { left = "l", right = "t", kern = -1 },
]

Однако наши шрифты поддерживают и диакритику, так что для буквы a нам нужно учесть все вариации с диакритикой.

manual_kerning = [
    { left = "V", right = "a", kern = -1 },
    { left = "V", right = "à", kern = -1 },
    { left = "V", right = "á", kern = -1 },
    { left = "V", right = "â", kern = -1 },
    # ...и так далее

Чтобы упростить это, можно использовать параметр alts и добиться того же результата:

manual_kerning = [
    { left = "V", right = "a", kern = -1, alts = true }
]

Вот карта, используемая для идентификации alts символа:

// для ручного кернинга скопируйте параметры для alts/диакритики
let alt: HashMap<char, &'static str> = HashMap::from_iter([
    ('A', "ÀÁÂÃÄÅ"),
    ('a', "àáâãäå"),
    ('C', "Ç"),
    ('c', "ç"),
    ('E', "ÈÉÊË"),
    ('e', "èéêë"),
    ('I', "ÌÍÎÏ"),
    ('I', "ÌÍÎÏ"),
    ('i', "ìíîï"),
    ('N', "Ñ"),
    ('n', "ñ"),
    ('O', "ÒÓÔÕÖ"),
    ('o', "òóôõö"),
    ('U', "ÙÚÛÜ"),
    ('u', "ùúûü"),
    ('Y', "Ÿ"),
    ('y', "ÿ"),
]);

Автоматический кернинг

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

Как же найти смещение кернинга для пары букв? Давайте возьмём для примера LV.

Чтобы определить их значение кернинга, нужно попиксельно перемещать V влево, пока она не станет максимально близко к L, при этом не касаясь её. Если мы переместим её на один пиксель...

Они не касаются друг друга, так что -1 нас устраивает. А что произойдёт, если мы сместим её ещё раз?

Нет, нас это не устраивает, два пикселя касаются (касание углов тоже считается). Значит  -2 — это перебор, и вычисленный кернинг для LV равен -1!

Код для этого выглядит так:

impl Point {
    fn surrounding(&self) -> [Self; 8] {
        let Point { x, y } = *self;
        [
            Self::new(x + 1, y),
            Self::new(x - 1, y),
            Self::new(x, y + 1),
            Self::new(x, y - 1),
            Self::new(x + 1, y + 1),
            Self::new(x - 1, y - 1),
            Self::new(x + 1, y - 1),
            Self::new(x - 1, y + 1),
        ]
    }
}

// вычисляем кернинг для этого глифа и глифа справа
pub fn calculate_kerning(
    left: &BitmapGlyph,
    right: &BitmapGlyph,
    min: i16
) -> Option<NonZero<i16>> {
    // начальная позиция находится в двух пикселях после первой буквы
    let mut kern: i16 = 0;
    let mut offset: i16 = left.max_pixel.x + 2;

    // если у левой буквы нет блокировки пикселей, мы не хотим
    // выполнять кернинг до бесконечности, поэтому останавливаемся на нуле
    while offset > 0 && kern > min {
        offset -= 1;

        // переносим каждый пиксель в правом изображении на смещение,
        // а затем проверяем, не привело ли это к касанию пикселей
        if right
            .pixels
            .iter()
            .map(|p| Point::new(p.x + offset, p.y))
            .any(|p| {
                // после переноса пикселя проверяем все окружающие его позиции
                // если какая-то из этих позиций касается пикселей левого глифа,
                // то такой кернинг нас не устраивает
                p.surrounding()
                    .iter()
                    .any(|adj| left.pixels.contains(adj))
            })
        {
            break;
        }

        // этот сдвиг не вызвал проблем, так что увеличиваем кернинг
        kern -= 1;
    }

    NonZero::new(kern)
}

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

// пропускаем выполненный вручную кернинг
let ignore: HashSet<(char, char)> = kerning
    .iter()
    .map(|k| (k.left, k.right))
    .collect();
let skip_left: HashSet<char> = desc
    .skip_kerning_left
    .chars()
    .collect();
let skip_right: HashSet<char> = desc
    .skip_kerning_right
    .chars()
    .collect();

// вычисляем автоматический кернинг
let min_kern = desc.auto_kerning_min.unwrap_or(i16::MIN);
kerning.par_extend(
    glyphs
        .par_iter()
        .filter(|left| !skip_left.contains(&left.chr))
        .map(|left| {
            glyphs
                .par_iter()
                .filter(|right| !skip_right.contains(&right.chr))
                .map(move |right| (left, right))
        })
        .flatten()
        .filter(|(left, right)| !ignore.contains(&(left.chr, right.chr)))
        .filter_map(|(left, right)| {
            calculate_kerning(left, right, min_kern)
                .map(|kern| {
                    KerningPair {
                        left: left.chr,
                        right: right.chr,
                        kern: kern.get(),
                        alts: None,
                    }
                })
        }),
);

Я не буду объяснять каждую строку, потому что этот код взаимодействует со множеством разных частей кодовой базы и выполняет обширный функционал. Если вкратце, то он игнорирует параметры в файле TOML шрифта и параллельно вычисляет при помощи rayon все пары кернинга.

Вычислив весь кернинг, мы можем создать файлы TTF.

Этап 5: экспорт

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

Файлы TrueType

Формат OpenType довольно сложен и имеет множество возможностей, ненужных нам в этих шрифтах.

Файлы TTF — это двоичные файлы, состоящие из блоков данных, называемых таблицами; каждая из таблиц содержит различную информацию о шрифте. Шрифт начинается с директории таблиц, в которой указано расположение каждой таблицы в памяти, чтобы парсеры шрифтов могли с лёгкостью перемещаться и находить нужную им информацию.

Моя программа для экспорта заполняет следующие таблицы:

Таблица

Описание

head

Глобальная информация о шрифте

hhea

Информация о горизонтальной структуре

maxp

Требования к памяти

OS/2

Требования шрифтов OpenType

hmtx

Горизонтальные метрики

cmap

Сопоставление символов с индексом глифа

loca

Сопоставление индекса с расположением таблицы glyf

glyf

Данные глифов (контуры)

kern

Пары кернинга

name

Строки с информацией о шрифте, авторе, стиле, авторском праве
и так далее.

post

Обязательна для валидного файла TTF

Я не буду подробно разбирать всё это. Мой код для записи таблиц выглядит так:

// таблица `hmtx`
data.begin_table(Tag::Hmtx);
{
    // longHorMetric - hMetrics[numberOfHMetrics]
    for g in &font.glyphs {
        // advanceWidth -  увеличить ширину в единицах дизайна шрифтов
        data.write_u16(px_to_un(g.adv as i16) as u16);

        // lsb - направляющая левой части глифа в единицах дизайна шрифтов.
        data.write_i16(px_to_un(g.lsb));
    }
}
data.end_table();

Каждой таблице нужно записать своё расположение, длину и контрольную сумму, в этом нам помогают begin_table() и end_table().

Функция px_to_un() преобразует пиксели в единицы измерения шрифтов. Она выглядит так:

let fake_height = 16;
let units_per_em = (2048 / fake_height) * fake_height;
let scale = units_per_em / fake_height;
let px_to_un = |x: i16| x * scale;

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

Поэтому решил, что этот базовый размер для всех моих шрифтов будет равен 16, то есть при рендеринге пиксельного шрифта с таким размером пиксели всегда будут размером ровно в 1 px. Если нужно отмасштабировать текст без колебаний, можно просто отрисовывать его в размерах, кратных 16, то есть 324864 и так далее.

Листы тайлов

Шрифты экспортируются и как листы тайлов. В отличие от исходных листов, в которых символы группируются по схожести, эти листы отсортированы по их кодовым точкам Unicode:

A tile sheet of pixel font characters
Лист тайлов символов пиксельного шрифта

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

{
  "cols": 14,
  "rows": 13,
  "tile_w": 8,
  "tile_h": 17,
  "baseline": 14,
  "line_gap": 0,
  "space_w": 3,
  "glyphs": [
        {
            "chr": "\u0000",
            "lsb": 0,
            "adv": 8
        },
        {
            "chr": "!",
            "lsb": 0,
            "adv": 2
        },
        {
            "chr": "\"",
            "lsb": 0,
            "adv": 4
        },
        {
            "chr": "#",
            "lsb": 0,
            "adv": 7
        },

В этом файле данных также содержится таблица кернинга:

    "kerning": [
        {
            "left": "i",
            "right": "j",
            "kern": -3
        },
        {
            "left": "i",
            "right": "ì",
            "kern": -1
        },
        {
            "left": "j",
            "right": "j",
            "kern": -2
        },

Чтобы сделать это, я поместил данные в следующие структуры и использовал serde, чтобы сериализовать их в различные поддерживаемые форматы (JSON, XML, TOML и так далее).

#[derive(Debug, Serialize)]
struct Sheet {
    cols: u32,
    rows: u32,
    tile_w: u32,
    tile_h: u32,
    baseline: i16,
    line_gap: i16,
    space_w: i16,

    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    glyphs: Vec<Glyph>,

    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    kerning: Vec<KerningPair>,
}

#[derive(Debug, Serialize)]
struct Glyph {
    chr: char,
    lsb: i16,
    adv: i16,
}

#[derive(Debug, Serialize)]
struct KerningPair {
    left: char,
    right: char,
    kern: i16,
}

Упакованные атласы

Так же экспортируются версии с глифами, плотно упакованными в атлас текстур:

Font characters packed tightly into a texture atlas

Для этого я использовал свой крейт прямоугольной упаковки crunch:

let PackedItems { w, h, mut items } = {
    let items: Vec<Item<usize>> = font
        .glyphs
        .iter()
        .enumerate()
        .filter(|(_, g)| g.pixels.len() > 0)
        .map(|(i, g)| {
            let w = ((g.max_pixel.x - g.min_pixel.x) + 2) as usize;
            let h = ((g.max_pixel.y - g.min_pixel.y) + 2) as usize;
            Item::new(i, w, h, Rotation::None)
        })
        .collect();
    crunch::pack_into_po2(2048, items).unwrap()
};

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

{
    "size": 12,
    "line_gap": 1,
    "space_w": 3,
    "glyphs": [
    {
        "chr": "\u0000",
        "x": 0,
        "y": 0,
        "w": 7,
        "h": 9,
        "off_x": 0,
        "off_y": -8,
        "adv": 8
    },
    {
        "chr": "!",
        "x": 118,
        "y": 36,
        "w": 1,
        "h": 7,
        "off_x": 0,
        "off_y": -7,
        "adv": 2
    },

Они сериализуются и экспортируются в разные форматы точно так же, как и листы:

#[derive(Debug, Serialize)]
struct Atlas {
    size: u32,
    line_gap: i16,
    space_w: i16,

    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    glyphs: Vec<Glyph>,

    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    kerning: Vec<KerningPair>,
}

#[derive(Debug, Serialize)]
struct Glyph {
    chr: char,
    x: usize,
    y: usize,
    w: usize,
    h: usize,
    off_x: i16,
    off_y: i16,
    adv: i16,
}

#[derive(Debug, Serialize)]
struct KerningPair {
    left: char,
    right: char,
    kern: i16,
}

Этап 6: тестирование качества

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

Я начал с отображения всех букв и нескольких тестовых предложений. Предложения взяты из рассказа «Они сделаны из мяса» Терри Биссона.

Sample text with test sentences

Затем последовала коллекция тестовых слов. Эти слова не случайны, они взяты из Text for Proofing Fonts: A farewell to The Quick Brown Fox — очень полезной стратегии улучшения тестирования качества шрифтов.

Sample text with a bunch of random test words

Мне хотелось, чтобы все сочетания цифр выглядели красиво, поэтому далее я отрендерил все возможные их пары вместе с символами валют:

Sample text with paired numbers and currency symbols

Затем я отрендерил огромную строку пар кернинга в верхнем, нижнем и смешанном регистрах:

Sample text of uppercase kerning pairs
Sample text of lowercase kerning pairs
Sample text of mixed-case kerning pairs

Последнее из показанных выше изображений довольно длинное. Наконец, я отрендерил тестовую пунктуацию:

Sample text of letter/punctuation combos
Sample text of letter/punctuation combos
Sample text of letter/punctuation combos

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

Этап 7: развёртывание

Я превзошёл свою изначальную цель — сотню шрифтов, ведь теперь у меня было аж 175 пиксельных шрифтов, которые нужно было как-то выложить онлайн для скачивания. Существует множество площадок для онлайн-продаж, но я решил остаться на проверенной мной itch.io.

Itch.io logo
itch.io — это открытый маркетплейс для независимых творцов

Создание проектов itch.io

Самая утомительная часть развёртывания заключалась в создании отдельного проекта itch под каждый из шрифтов.

A sample of my pixel font itch projects page

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

Сборка и загрузка

Мой скрипт развёртывания состоит из трёх этапов:

  • Проверка того, что PIFO скомпилировалась

  • Сборка ассетов шрифтов

  • Добавление в проект itch каждого шрифта новых ассетов

С первым шагом всё просто, я компилирую саму PIFO в режиме релиза, поэтому это происходит максимально быстро:

cd pifo
cargo build --release || exit /b %errorlevel%

Далее я при помощи PIFO собираю нужный мне шрифт.

cd ..\input
..\pifo\target\release\pifo --output "../distro/faraway" --all --input "Faraway*"

--input "Faraway*" означает, что она найдёт каждый шрифт, начинающийся с этого текста, и скомпилирует/упакует их все вместе. В данном примере это Faraway - RegularFaraway - Bold и так далее. Все они объединяются в один файл семейства шрифтов Faraway.

Наконец, для развёртывания я использую butler — очень удобный инструмент командной строки, созданный itch специально для этой цели.

butler push ../distro/faraway chevyray/pixel-font-faraway:assets

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

Заключение

Вот так я выпустил 175 пиксельных шрифтов на itch.io при помощи написанных мною на Rust инструментов!

Надеюсь, статья показалась вам интересной, информативной, а может, и полезной для тех, кто изучает Rust или интересуется, что нужно для реализации проекта такого масштаба.

Preview of 40 of my pixel font families

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


  1. chiliec
    22.08.2024 16:36
    +2

    Вот бы кто-нибудь сделал такой же классный пак, но с поддержкой кириллицы...


  1. anonymous
    22.08.2024 16:36

    НЛО прилетело и опубликовало эту надпись здесь


    1. PatientZero Автор
      22.08.2024 16:36

      Скрытый текст


  1. chnav
    22.08.2024 16:36

    Самое интересное для меня лично, что узнал из статьи - это когда впервые встретился термин "кернинг". Только тогда осознал, что пиксельный шрифт не всегда означает monospace. Никогда не задумывался над этим, хотя очень логично. Но я совершенно не играю в игры (последняя была Doom 2). Пожалуй больше эти шрифты встретить негде, так что простительно ))


  1. ded_Arkash
    22.08.2024 16:36
    +3

    лады, но без кириллицы скукота