Go ebiten: разбираемся с рендерингом и позиционированием текста

Перед вами первая заметка на тему разработки игр на Go с использованием библиотеки ebiten (также известный как Ebitengine).

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

bp85zt5myvgrlojpkmljdhvcouo.png


Перед тем, как мы приступим

Мы будем придерживаться следующей структуры проекта:

example/
  _assets/*
  main.go
  go.mod
  go.sum

В директории _assets будут располагаться все ресурсы: спрайты, шрифты, звуки и всё остальное. Эти ресурсы будут встроены в исполняемый файл через go:embed.

//go:embed all:_assets
var gameAssets embed.FS

Поскольку наши примеры будут довольно простыми, всё приложение будет описано в одном файле main пакета (main.go).


Подготовка сцены

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

Создадим реализацию ebiten.Game, которая будет рисовать подобную сцену:

const (
    windowWidth  = 32 * 14
    windowHeight = 32 * 8
)

type Game struct {}

func (g *Game) Update() error { return nil }

func (g *Game) Draw(screen *ebiten.Image) {
    // Локальные сокращения, чтобы уменьшить код по ширине
    // (Формат статьи накладывает свои ограничения)
    const w = windowWidth
    const h = windowHeight

    ebitenutil.DrawRect(screen, 0, 0, w, h, color.White)

    // Рисуем сетку (32x32 и 64x64)
    gridColor64 := &color.RGBA{A: 50}
    gridColor32 := &color.RGBA{A: 20}
    for y := 0.0; y < h; y += 32 {
        ebitenutil.DrawLine(screen, 0, y, w, y, gridColor32)
    }
    for y := 0.0; y < h; y += 64 {
        ebitenutil.DrawLine(screen, 0, y, w, y, gridColor64)
    }
    for x := 0.0; x < w; x += 32 {
        ebitenutil.DrawLine(screen, x, 0, x, h, gridColor32)
    }
    for x := 0.0; x < w; x += 64 {
        ebitenutil.DrawLine(screen, x, 0, x, h, gridColor64)
    }
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return windowWidth, windowHeight
}

Функция main может выглядеть как-то так:

func main() {
    ctx := newContext()
    game := &Game{ctx: ctx}
    ebiten.SetWindowTitle("Text Rendering")
    ebiten.SetWindowSize(windowWidth, windowHeight)
    if err := ebiten.RunGame(game); err != nil {
        ctx.Critical(err)
    }
}

Результат запуска программы:

wwa-bun1wk1z1c-5ekgsifzimyu.png


Выводим простой текст

Чтобы было интереснее, мы не будем использовать моноширинный шрифт. Для своих примеров я возьму шрифт Zack and Sarah.

Сначала нам понадобится создать объект font.Face из файла _assets/font.ttf. В настоящей игре загрузкой и инициализацией подобных ресурсов будет заниматься загрузчик (resource loader), но сейчас мы рассмотрим пример загрузки шрифта с самого начала.

// Считайте эти переменные параметрами создания шрифта
var (
    fontSize = 18
    filename = "font.ttf"
)

// В embed FS разделителем является "/", даже на Windows
r, err := gameAssets.Open("_assets/" + filename)
if err != nil {
    ctx.Critical(err)
}
defer func() {
    if err := r.Close(); err != nil {
        ctx.Warnf("closing %q: font reader: %v", filename, err)
    }
}()
fontData, err := io.ReadAll(r)
if err != nil {
    ctx.Criticalf("reading %q font data: %v", filename, err)
}
tt, err := opentype.Parse(fontData)
if err != nil {
    ctx.Criticalf("parsing %q font: %v", filename, err)
}
fontFace, err := opentype.NewFace(tt, &opentype.FaceOptions{
    Size:   float64(fontSize),
    DPI:     96,
    Hinting: font.HintingFull,
})
if err != nil {
    ctx.Criticalf("creating a font face for %q: %v", filename, err)
}
return fontFace

Будем считать, что созданный fontFace нам всегда будет доступен через ctx.GetFontFace("font.ttf").


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

Добавим в метод Game.Draw эти строки:

s := "Dangan Ronpa!"
fontFace := ctx.GetFontFace("font.ttf")
var opts ebiten.DrawImageOptions
opts.ColorM.ScaleWithColor(color.RGBA{A: 255})
opts.GeoM.Translate(64, 64)
text.DrawWithOptions(screen, s, fontFace, &opts)

kgepvlygak9b1a3mk4_wcw9anwo.png

Ожидали такой результат? Так или иначе, сейчас будем разбираться, что это за позиционирование такое и как с этим жить.


Dot position

Мы вызвали DrawWithOptions, указав позицию отрисовки (64, 64). При этом даже сама документация явно подчёркивает, что это не левый верхний угол, а некий dot position. Перед тем, как мы начнём осваивать эти детали, посмотрим на результат ещё раз и проведём дополнительный эксперимент.

kjbjm1xd1-9xqlgjkl8sfw4o9ii.png

Парочка наблюдений:


  • Текст почти целиком расположен над y=64
  • Часть текста расположена ниже y=64

Сделаем предположение, что текст растёт наверх. Проверим это, добавив перенос строки.

- s := "Dangan Ronpa!"
+ s := "Dangan Ronpa!\n~~Sore wa chigau yo!"

jlvfcszgicituepbjvcfvreptzk.png

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


ebiten использует пакет golang.org/x/image/font. Именно оттуда копируется часть документации для работы с текстом.

Нам будет полезна следующая иллюстрация:

3mk1nn74sbjmirxnaznvsoijw9y.png

Dot position из документации — это baseline. Позиция y задаёт стартовую позицию отрисовки, через эту ось будет проходить baseline.

Часть текста, что оказалась ниже baseline — это descent.

Когда мы выводим несколько строк текста через один вызов text.DrawWithOptions, ebiten будет спускаться на line height пикселей вниз для каждого символа \n.

Теперь обратим внимание на font.Face. Для нас очень полезен метод font.Face.Metrics(), который возвращает font.Metrics.

Если мы посмотрим на поля font.Metrics, то увидим связь.


  • Metrics.Height — line height
  • Metrics.Descent — выше упомянутый descent
  • и так далее

Функция text.BoundString позволяет измерить область, которую займёт некий текст, отрисованный выбранным шрифтом. Так мы можем узнать высоту и ширину результата.

Итак, чтобы отрисовать текст в координате (x, y) так, чтобы это было левым верхним углом, нам достаточно смещать текст по оси y на выбранную величину. Этой выбранной величиной может быть Metrics.Ascent или Metrics.CapHeight.

ypos := 64.0
// Значение CapHeight может быть отрицательным, поэтому
// лучше всегда брать модуль от этого числа
ypos += math.Abs(float64(fontFace.Metrics().CapHeight.Floor()))

lyeztzit0kj7lvfw03bisbhvhtq.png

Если применять CapHeight, часть текста может выходить за пределы y, но обычно это ожидаемое поведение. Если хочется этого избежать, стоит использовать Ascent для смещения, но тогда будет сложнее реализовать vertical align center.


Компонент Label

Фреймворки для создания игр часто предоставляют компонент типа Label.

В ebiten нет такого компонента, поэтому нам нужно будет создавать его самостоятельно.

Особенности нашего Label:


  • Интуитивное позиционирование (без всяких dot position)
  • Выбор цвета для текста и для содержащего его прямоугольника
  • Настройка выравнивания текста по вертикали и горизонтали
  • Выбор стратегии по расширению (grow directions)

Другими полезными возможностями могут быть автоматические переносы слов на новую строку (вместо расширения блока) и обрезание текста, а также отступы (padding).

Введём некоторые типы и константы, а затем приступим к реализации самих алгоритмов:

type AlignVertical uint8

const (
    AlignVerticalTop AlignVertical = iota
    AlignVerticalCenter
    AlignVerticalBottom
)

type AlignHorizontal uint8

const (
    AlignHorizontalLeft AlignHorizontal = iota
    AlignHorizontalCenter
    AlignHorizontalRight
)

type GrowVertical uint8

const (
    GrowVerticalDown GrowVertical = iota
    GrowVerticalUp
    GrowVerticalNone
)

type GrowHorizontal uint8

const (
    GrowHorizontalRight GrowHorizontal = iota
    GrowHorizontalLeft
    GrowHorizontalNone
)

type Label struct {
    X float64
    Y float64

    Width float64
    Height float64

    Text string

    Color           color.RGBA
    BackgroundColor color.RGBA

    AlignVertical   AlignVertical
    AlignHorizontal AlignHorizontal
    GrowVertical    GrowVertical
    GrowHorizontal  GrowHorizontal

    Visible bool

    fontFace   font.Face
    capHeight  float64
    lineHeight float64
}

func NewLabel(fontFace font.Face) *Label {
    m := fontFace.Metrics()
    capHeight := math.Abs(float64(m.CapHeight.Floor()))
    lineHeight := float64(m.Height.Floor())
    return &Label{
        fontFace:   fontFace,
        capHeight:  capHeight,
        lineHeight: lineHeight,
        Color:      color.RGBA{A: 0xff},
        Visible:    true,
    }
}

По умолчанию получаем следующее поведение:


  • Выравнивание по левому верхнему углу
  • Расширение области текста вправо и вниз
  • Чёрный цвет текста, а прямоугольник с фоном — прозрачный

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

Использоваться объекты типа Label будут так: кто-то, кто управляет текущей сценой, будет вызывать метод Draw() у каждого графического элемента. Графические элементы могут храниться в виде слайса из интерфейсов с этим методом, но мы можем представить, что в Game хранится слайс *Label (вернёмся к этому конце статьи).

Первая версия Draw(), без поддержки выравнивания, будет выглядеть так:

func (l *Label) Draw(screen *ebiten.Image) {
    if !l.Visible {
        return
    }
    posX := l.X
    posY := l.Y + l.capHeight
    var opts ebiten.DrawImageOptions
    opts.ColorM.ScaleWithColor(l.Color)
    opts.GeoM.Translate(posX, posY)
    text.DrawWithOptions(screen, l.Text, l.fontFace, &opts)
}


Grow directions

Рабочий размер Label (width, height) делает выравнивание текста более предсказуемым. Вместо того чтобы вычислять размеры исходя от позиции и размера отображаемого текста, мы можем иметь фиксированное пространство, относительно которого мы выполняем преобразования. В случае, если width и height равны нулю, то рабочим размером будет считаться результат text.BoundString.

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

Пример: Label имеет размеры width=128, height=32. Если мы выводим текст, который умещается в этом пространстве, то никакого расширения не будет. Однако, иногда мы хотим разрешить расширение в одной или более сторон, либо явно его запретить и иную стратегию (например, сокращать текст).

Добавим следующий код в функцию Label.Draw:

// image.Rectangle имеет целочисленные поля, как и image.Point,
// поэтому мы будем использовать отдельные компоненты, где
// x0, y0 - min; x1, y1 - max
var (
    containerX0 float64
    containerY0 float64
    containerX1 float64
    containerY1 float64
)
bounds := text.BoundString(l.fontFace, l.Text)
boundsWidth := float64(bounds.Dx())
boundsHeight := float64(bounds.Dy())
if l.Width == 0 && l.Height == 0 {
    // Автоматическое проставление рабочей области
    containerX0 = posX
    containerY0 = posY
    containerX1 = posX + boundsWidth
    containerY1 = posY + boundsHeight
} else {
    containerX0 = posX
    containerY0 = posY
    containerX1 = posX + l.Width
    containerY1 = posY + l.Height
    if delta := boundsWidth - l.Width; delta > 0 {
        switch l.GrowHorizontal {
        case GrowHorizontalRight:
            containerX1 += delta
        case GrowHorizontalLeft:
            containerX0 -= delta
        case GrowHorizontalNone:
            // Ничего не делаем
        }
    }
    if delta := boundsHeight - l.Height; delta > 0 {
        switch l.GrowVertical {
        case GrowVerticalDown:
            containerY1 += delta
        case GrowVerticalUp:
            containerY0 -= delta
            posY -= delta
        case GrowVerticalNone:
            // Ничего не делаем
        }
    }
}
var (
    containerWidth  float64 = containerX1 - containerX0
    containerHeight float64 = containerY1 - containerY0
)

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

Имея координаты и размеры контейнера, мы можем отрисовать прямоугольный фон:

if l.BackgroundColor.A != 0 {
    // Пытаюсь уместить вызов DrawRect по ширине...
    x0 := containerX0
    y0 := containerY0 - l.capHeight
    w := containerWidth
    h := containerHeight
    ebitenutil.DrawRect(screen, x0, y0, w, h, l.BackgroundColor)
}


Выравнивание текста по центру

Начнём с центрирования по вертикали.

numLines := strings.Count(l.Text, "\n") + 1
switch l.AlignVertical {
case AlignVerticalTop:
    // Ничего не делаем
case AlignVerticalCenter:
    posY += (containerHeight - l.estimateHeight(numLines)) / 2
case AlignVerticalBottom:
    posY += containerHeight - l.estimateHeight(numLines)
}

Метод Label.estimateHeight:

func (l *Label) estimateHeight(numLines int) float64 {
    // Начинаем с высоты, которая нам потребуется для первой строки
    estimatedHeight := l.capHeight
    if numLines >= 2 {
        // Добавляем высоту для всех остальных строк
        estimatedHeight += (float64(numLines) - 1) * l.lineHeight
    }
    return estimatedHeight
}

Здесь важно использовать такую формулу подсчёта высоты текста, которая будет выдавать одинаковые результаты для одинакового numLines. Её можно выразить через CapHeight и LineHeight. Если использовать boundsHeight, то мы будем получать не очень красивый результат, где выравнивание разного текста будет то выше, то ниже, так как содержимое самой строки будет влиять на высоту отрисованного текста.


Укрощаем многострочный текст

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

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

В качестве небольшой оптимизации, мы будем обрабатывать выравнивание по левому краю, как и раньше. Мы так же могли бы оптимизировать частный случай, когда центрирование по горизонтали (или правому краю) применяется к однострочному тексту.

Давайте вспомним, как выглядел код отрисовки текста ранее:

if l.Text == "" {
    return
}
var opts ebiten.DrawImageOptions
opts.ColorM.ScaleWithColor(l.Color)
opts.GeoM.Translate(posX, posY)
text.DrawWithOptions(screen, l.Text, l.fontFace, &opts)

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

Обновлённый код будет выглядеть так:

var opts ebiten.DrawImageOptions
opts.ColorM.ScaleWithColor(l.Color)

if l.AlignHorizontal == AlignHorizontalLeft {
    opts.GeoM.Translate(posX, posY)
    text.DrawWithOptions(screen, l.Text, l.fontFace, &opts)
    return
}
// Нужно обрабатывать текст построчно, выравнивая каждую
// строку отдельно
textRemaining := l.Text
offsetY := 0.0
for {
    nextLine := strings.IndexByte(textRemaining, '\n')
    lineText := textRemaining
    if nextLine != -1 {
        lineText = textRemaining[:nextLine]
        textRemaining = textRemaining[nextLine+len("\n"):]
    }
    lineBounds := text.BoundString(l.fontFace, lineText)
    lineBoundsWidth := float64(lineBounds.Dx())
    offsetX := 0.0
    switch l.AlignHorizontal {
    case AlignHorizontalCenter:
        offsetX = (containerWidth - lineBoundsWidth) / 2
    case AlignHorizontalRight:
        offsetX = containerWidth - lineBoundsWidth
    }
    opts.GeoM.Reset()
    opts.GeoM.Translate(posX+offsetX, posY+offsetY)
    text.DrawWithOptions(screen, lineText, l.fontFace, &opts)
    if nextLine == -1 {
        break
    }
    offsetY += l.lineHeight
}


Создаём и размещаем объекты

Создадим интерфейс Drawer; этот интерфейс будут реализовывать все компоненты и графические эффекты. Проще говоря, Drawer — это такой объект сцены, у которого есть Draw(), но нет метода Update().

type Drawer interface {
    Draw(dst *ebiten.Image)
}


Заметим, что на практике полезно иметь метод типа IsDisposed() почти во всех интерфейсах объектов сцены. Например, если какой-то элемент нужно удалить, у него будет вызван Dispose() после чего IsDisposed() будет возвращать false и объект будет удалён со сцены в следующем логическом кадре.

Добавим объекты Label в Game:

  type Game struct {
    ctx     *context
+   drawers []Drawer
  }

Где-то в инициализации Game (или в процессе игры) мы добавляем новые графические элементы в этот слайс. После этого они будут отрисовываться в методе Game.Draw.

// Добавляем цикл отрисовки объектов в метод Game.Draw
for _, d := range g.drawers {
    d.Draw(screen)
}

Добавим первый Label на сцену:

l := NewLabel(ctx.GetFontFace("font.ttf"))
l.X = 64 * 2
l.Y = 64
l.Width = 64 * 3
l.Height = 64 * 2
l.AlignVertical = AlignVerticalCenter
l.AlignHorizontal = AlignHorizontalCenter
l.Text = "ebiten\nis great"
l.Color = color.RGBA{G: 100, B: 255, A: 255}
l.BackgroundColor = color.RGBA{R: 100, G: 200, B: 100, A: 160}

game.drawers = append(game.drawers, l)

nfujtozleh7itreyg-tohrgav7g.png

Поменяем некоторые параметры, уберём фон:

l := NewLabel(ctx.GetFontFace("font.ttf"))
l.X = 64 * 2
l.Y = 64
l.Width = 64 * 3
l.Height = 64 * 2
l.AlignVertical = AlignVerticalBottom
l.AlignHorizontal = AlignHorizontalRight
l.Text = "This text\nis so majestic"

p43uqjdimedkmavop9dc_pu1boi.png

В процессе игры мы можем динамически менять отображаемый текст через изменение поля Label.Text; все остальные параметры отрисовки так же можно изменять на лету, без повторного создания объекта Label. Единственное ограничение, которое мы ввели через наше API — это привязку к font.Face, но и его можно, при желании, убрать (например, добавив метод SetFontFace).


Межстрочный интервал

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

Если нам нужен другой размер текста, мы создаём Label, передав в функцию-конструктор font.Fact`, созданный с нужными параметрами.

Объект opentype.FaceOptions даёт конфигурировать многие параметры, но не межстрочный интервал. ebiten экспортирует функцию text.FaceWithLineHeight решает как раз эту задачу.

// lineSpacing - коэффициент увеличения межстрочного интервала;
// для 1.0 мы не выполняем избыточного заворачивания
if lineSpacing != 1 {
    h := float64(fontFace.Metrics().Height.Round()) * lineSpacing
    fontFace = text.FaceWithLineHeight(fontFace, math.Round(h))
}

Рекомендация: используйте округлённое значение для LineHeight. Значения вроде 17.9 будут выдавать очень странные результаты.

На скриншоте ниже показано сравнение трёх разных значений для lineSpacing.

zva1lp4qqxhn8bbwhxw8ou-ibha.png


Заключение

ep6meq_om60j_kjegvnnhietky4.png

Сегодня мы разобрались, что такое dot position (он же baseline). Мы научились позиционировать текст нужным нам образом, с предсказуемым origin и ориентацией.

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

На сегодняшний день разработка игр на Go — не очень распространённая практика. Русскоязычных сообществ и материалов довольно мало. Я приглашаю вас в телеграм канал go_gamedev, в котором можно обсуждать всё, что связанно с тематикой разработки игр на языке Go. Давайте делиться там своими статьями, проектами, библиотеками и инструментами.

А в комментариях напишите, о чём ещё мне стоит рассказать в формате заметки на хабре.

© Habrahabr.ru