Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 4 из 25 ~18 мин +50 XP

Спрайты: рисуем изображение

Первый ebitengine спрайт на экране — момент, после которого игру уже не остановить: вместо чёрного холста появляется герой из PNG с честной прозрачностью. В этом уроке загружаем картинку в ebiten.Image, рисуем её через ebiten drawimage с матрицей GeoM и разбираем прозрачность, фильтры и батчи — всё, что нужно знать о спрайтах на старте.

Содержание урока · 10 разделов

Чёрное окно из первых уроков честно тикает, но игроку от этого не весело: в 2D-игре девяносто процентов экрана — это картинки. Спрайт — прямоугольный кусочек изображения, который двигается по холсту, — базовая единица всего жанра: герой, враг, пуля, тайл травы и кнопка меню в конце концов тоже спрайт. Сегодня мы пройдём весь путь одного спрайта: PNG на диске → загрузка в память → ebiten.Image → DrawImage на экран. Прежде чем рисовать, вспомните каркас из урока про установку и цикл: весь код сегодня ляжет в метод Draw, а каркас, окно и Layout останутся прежними.

ebitengine спрайт: всё есть ebiten.Image#

Единственный тип графики в ядре движка — ebiten.Image: прямоугольник пикселей в формате premultiplied RGBA, реализующий стандартный интерфейс image.Image. Из него состоит всё, что вы видите: и экран, который движок передаёт в Draw, и каждый загруженный спрайт. Новый пустой прямоугольник создаёт ebiten.NewImage(ширина, высота), а из готового image.Image — например, декодированного PNG — спрайт делает ebiten.NewImageFromImage. Приятно, что движок защищает от невидимых объектов: NewImage паникует, если ширина или высота меньше единицы, и пустых картинок в игре не появится. Спрайт можно залить цветом через Fill с любым color.Color, очистить через Clear, узнать размер через Size или Bounds — это те же методы, что у прямоугольника из стандартной библиотеки, к которым добавилась рисующая половина API.

Ключевая идея, к которой вы будете возвращаться весь курс: Draw умеет рисовать не только на экран, но и в любой ebiten.Image. Сначала вы собираете картинку в невидимый offscreen-буфер — композит из слоёв, тайлов, частиц, — потом один раз переносите буфер на screen. Пока запомните сам принцип «экран — тоже Image», а применять его будем от урока про атласы и далее. Размер спрайта знать обычно не обязательно — движок рисует картинку в её родном размере, — но Bounds и Size пригождаются, когда нужно поставить спрайт в угол холста или выровнять по центру.

Загрузка PNG: embed и image.Decode#

Путь картинки в игру начинается с упаковки файла в бинарник. Стандартный embed встраивает файл прямо в исполняемую программу: директива //go:embed над переменной байтового среза делает PNG частью игры — и расплата одна, бинарник толстеет на размер ассетов. Такой подход избавляет от классических мук «куда делись мои картинки после сборки»: один исполняемый файл, ноль внешних зависимостей. Из встроенных байтов изображение декодирует стандартный image.Decode, который возвращает image.Image — а NewImageFromImage уже превращает его в ебитеновский спрайт.

Здесь живёт самый популярный подводный камень курса: пустой импорт пакета image/png. Функция image.Decode не умеет декодировать форматы сама — она спрашивает у зарегистрированных декодеров, какой из них возьмётся за ваши байты. Пакет image/png регистрирует PNG-декодер в своей функции init, поэтому его импортируют с подчёркиванием: _ "image/png". Забудете — получите на пустом месте ошибку «image: unknown format», хотя код кажется правильным. Для JPEG и GIF история та же, только пакеты другие; для атласов и кадрирования спрайтов позже пригодится и SubImage, который вырезает часть картинки без копирования пикселей. У embed есть и тихая особенность: при встраивании каталога файлы, чьи имена начинаются с точки или подчёркивания, пропускаются — .DS_Store не приедет в бинарник, но и _hero_draft.png тоже; такой файл нужно называть явно в директиве.

От файла на диске до ebiten.Image

GO
package main

import (
        "bytes"
        "embed"
        "image"
        _ "image/png"
        "log"

        "github.com/hajimehoshi/ebiten/v2"
)

//go:embed assets/hero.png
var heroPng []byte

func loadSprite(data []byte) *ebiten.Image {
        img, format, err := image.Decode(bytes.NewReader(data))
        if err != nil {
                log.Fatal(err)
        }
        _ = format // "png", если декодер зарегистрирован
        return ebiten.NewImageFromImage(img)
}

Директива embed превращает assets/hero.png в байтовый срез heroPng при сборке. image.Decode возвращает картинку, имя формата и ошибку — ошибка решает судьбу запуска, поэтому честный log.Fatal. NewImageFromImage конвертирует декодированный image.Image в premultiplied RGBA движка. Пустой импорт image/png обязателен: без него Decode вернёт «unknown format».

Зачем в загрузке спрайта импорт с подчёркиванием: _ "image/png"?

ebiten drawimage: позиция решает GeoM#

Рисует спрайт метод screen.DrawImage(img, op), где op — опции отрисовки ebiten.DrawImageOptions. Главная из них — GeoM, матрица геометрии: она решает, куда и как ляжет картинка. Нулевые опции рисуют спрайт в левом верхнем углу холста, в точке (0, 0) — это документированное поведение identity-матрицы, и это первое, что стоит проверить, когда картинка «не там». Позицию задаёт Translate: op.GeoM.Translate(100, 50) сдвигает точку отрисовки на 100 пикселей вправо и 50 вниз. Заметьте тип: у GeoM аргументы float64, даже если вы храните координаты целыми — движок живёт в дробных координатах, и это нам ещё пригодится в уроке про движение.

Второй практичный приём — поставить спрайт по границам холста: Bounds даёт прямоугольник картинки, и его Size превращается в ширину и высоту, от которых удобно отсчитывать углы. Всё это вместе открывает типовой паттерн Draw: залить фон сплошным цветом через screen.Fill с color.RGBA, затем нарисовать спрайты поверх с их координатами. Каждый вызов DrawImage копирует пиксели аргумента в момент вызова: если вы измените картинку после отрисовки, уже нарисованный кадр не пострадает — композиция кадра идёт от простых к сложным слоям.

Кадр: фон плюс герой в углу

GO
func (g *Game) Draw(screen *ebiten.Image) {
        screen.Fill(color.RGBA{R: 34, G: 32, B: 52, A: 255})

        op := &ebiten.DrawImageOptions{}
        size := hero.Bounds().Size()
        x := screen.Bounds().Dx() - size.X
        y := screen.Bounds().Dy() - size.Y
        op.GeoM.Translate(float64(x), float64(y))
        screen.DrawImage(hero, op)
}

Fill заливает холст тёмно-синим из палитры пиксель-арта, затем hero ставится в правый нижний угол: от ширины и высоты холста отнимаются размеры спрайта из Bounds. Bounds().Size() возвращает image.Point с полями X и Y, а Translate принимает float64 — отсюда явное приведение. Порядок важен: фон рисуется первым, герой ложится поверх.

Что сделает screen.DrawImage(img, op) с нулевыми опциями: op := &ebiten.DrawImageOptions{}?

Где окажется второй спрайт?

Прочитайте код и выберите, что он выведет:

op := &ebiten.DrawImageOptions{}

op.GeoM.Translate(100, 0)
screen.DrawImage(hero, op)

op.GeoM.Translate(100, 0)
screen.DrawImage(hero, op)

Прозрачность PNG и альфа-блендинг#

Почему герой из PNG не приезжает на экран в белом квадрате? Потому что PNG хранит альфа-канал — прозрачность каждого пикселя, — и движок честно её учитывает. Способ наложения называется альфа-блендингом: прозрачные пиксели источника не перекрывают фон, а подмешиваются к нему с коэффициентом альфы. В опциях отрисовки этим управляет поле Blend, и нулевое значение — как раз регулярный альфа-блендинг: его хватает для девяноста процентов 2D-игр, от слоёв фона до дыма и теней. Хотите полупрозрачный спрайт целиком — не ищите отдельную функцию: op.ColorScale.ScaleAlpha(0.5) умножает альфу на половину, и картинка встанет на фон стеклом.

Единственная тонкость, которую стоит знать заранее, — формат хранения. Ebitengine держит пиксели в premultiplied RGBA: альфа уже умножена на цветовые каналы, что упрощает композицию и защищает от артефактов по краям полупрозрачных пикселей. Ваша работа с этим форматом заканчивается на NewImageFromImage — конвертация из декодированного PNG происходит там автоматически, а рисовать вы можете не задумываясь. Классический артефакт, который всё же доезжает до экрана, — светлый ореол вокруг героя: он остаётся от полупрозрачных пикселей, которые художник когда-то стёр в редакторе не до нуля; лечится пересохранением с чистой альфой. Встретив в чужом коде ручные манипуляции с premultiplied значениями — через ReadPixels или шейдеры, — помните, что это низкоуровневая оптика, а не повседневный путь.

Фильтры и батчи: FilterNearest против FilterLinear#

Что происходит с пикселем спрайта, когда картинка растягивается в три раза? Отвечает поле Filter. Нулевое значение — FilterNearest: берётся ближайший пиксель, границы остаются резкими, и это дефолт, идеально совпадающий с желаниями пиксель-арт художника. FilterLinear размывает границы интерполяцией соседних пикселей — так рисуют «гладкую» графику и увеличенные скриншоты, но для ретро-стиля линейный фильтр — синоним мыла. Практическое правило: выбрали пиксель-арт — не трогайте фильтр вовсе, он уже правильный.

За кадром у DrawImage работает ещё одна приятная механика — автоматические батчи. Движок складывает подряд идущие вызовы в одну пачку для видеокарты, но только при трёх условиях: цель отрисовки одна, Blend одинаковый и Filter одинаковый. Нарушите любое — например, чередуя полупрозрачные спрайты с обычными, — и пачка разобьётся, а производительность просядет на сотнях объектов. Замечу честно: пока в кадре десятки спрайтов, батчи — невидимая механика, и следить за ней скучно. Но этап «сотня объектов» в каждом проекте наступает внезапно — в тот день, когда вы добавите сто пуль, и прежний код вдруг съест бюджет кадра. Запомните правило «одинаковые опции подряд», к нему мы вернёмся с профилировщиком в уроке про 60 FPS. Из всего богатства опций в таблице ниже — только повседневный минимум.

DrawImageOptions: поле за полем#

ПолеЗначение по умолчаниюДля чего
GeoMidentity — рисует в (0, 0)позиция, масштаб, поворот спрайта
ColorScaleidentity (1, 1, 1, 1)окраска и альфа: ScaleAlpha для полупрозрачности
Blendобычный альфа-блендингспособ наложения источника на фон
FilterFilterNearestфильтр при растяжении: nearest для пиксель-арта, linear для гладкой графики

Какой фильтр используется по умолчанию и почему он удачен для пиксель-арта?

Шесть ошибок первых спрайтов одним списком#

  • image: unknown format — забыли пустой импорт _ "image/png"
  • Спрайт прилип к углу — забыли op.GeoM.Translate
  • Второй спрайт съехал дальше — Translate накопился, нужен GeoM.Reset()
  • Белая рамка вокруг героя — PNG без альфа-канала
  • Паника при отрисовке — рисуете screen в сам screen или используете disposed-спрайт
  • Мыло вместо пикселей — кто-то включил FilterLinear для пиксель-арта

Соберите отрисовку спрайта

Восстановите три пропуска в пути спрайта: опции, матрица и вызов.

var hero *ebiten.Image // загружен через ebiten.NewImageFromImage func (g *Game) Draw(screen *ebiten.Image) { op := &ebiten.Options{} op..Translate(100, 50) screen.(hero, op) }

Путь спрайта от файла до экрана

Расставьте шаги загрузки и отрисовки спрайта по порядку.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

У вас теперь есть полный конвейер: PNG через embed, image.Decode с пустым импортом, NewImageFromImage и DrawImage с GeoM — этого хватает для любого статичного кадра. Спрайт — только начало: те же опции DrawImage обслужат и тайлы, и частицы, и кадры анимации, поэтому конвейер этого урока вы будете переиспользовать до самого итогового проекта. Следующий шаг очевиден: заставить координаты меняться 60 раз в секунду, и тут всплывает разница тиков и кадров, которую мы разбираем в уроке про движение и дельту. По мере роста числа картинок появится вопрос организации: вырезание кусочков из общего листа разберёт урок про атласы, а аккуратные пивоты и границы спрайта — урок про прямоугольники (08-pryamougolniki). До встречи там: возьмите любой PNG с альфой, поставьте героя в четыре угла холста и убедитесь, что predict про накопление Translate вы разгадали не зря.

Термины урока

Термин

ebiten.Image

нажмите, чтобы перевернуть
Определение

Прямоугольник пикселей premultiplied RGBA — и экран, и спрайты. Создание: NewImage или NewImageFromImage; методы Fill, Clear, Size, Bounds, SubImage.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Вся графика Ebitengine — ebiten.Image: экран из Draw, загруженные спрайты и offscreen-буферы одного типа.
  • PNG идёт в игру через embed, image.Decode с пустым импортом image/png и NewImageFromImage — иначе unknown format.
  • DrawImage с нулевыми опциями рисует в (0, 0); позицию задаёт op.GeoM.Translate с float64-аргументами.
  • GeoM накапливает Translate между вызовами: переиспользуя опции, сбрасывайте матрицу через GeoM.Reset().
  • Прозрачность PNG работает из коробки через альфа-блендинг; полупрозрачность целиком — ColorScale.ScaleAlpha.
  • FilterNearest по умолчанию держит пиксель-арт резким; батчи собираются при совпадении цели, Blend и Filter подряд.

Домашнее задание

Соберите проект по коду урока: найдите или нарисуйте PNG с прозрачностью, положите его в assets и загрузите через embed. Нарисуйте спрайт в центре холста, рассчитав координаты из Bounds холста и спрайта, затем добавьте вторую отрисовку через те же опции с повторным Translate и убедитесь, что увидели именно предсказанное накопление. Поставьте ColorScale.ScaleAlpha(0.5) и сравните с обычной отрисовкой. Наконец, попробуйте screen.DrawImage(screen, op) — и прочитайте панику в консоли: это лучше любого заучивания документации. Отдельно включите в эксперимент SubImage: вырежьте из hero произвольный прямоугольник и нарисуйте его отдельно — пригодится при переходе к атласам.

Личные заметкиПусто
Хранятся только в вашем браузере (Ebitengine · Спрайты: рисуем изображение)

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

Спрайт рисуется, но с белой рамкой вокруг героя — в чём дело?

Рамка почти всегда означает PNG без альфа-канала: фон картинки непрозрачный белый, и прозрачность блендить не из чего. Пересохраните файл с прозрачностью, а затем проверьте, что рисуете именно декодированный PNG, а не промежуточную копию без альфы.

Обязательно ли хранить ассеты через embed?

Нет: можно читать файлы с диска через os.ReadFile — удобно на стадии разработки, когда картинки меняются часто. Embed выигрывает в релизе: один бинарник без раздаточных папок, а оба пути сходятся в один и тот же image.Decode. Третий путь — зарегистрировать собственный формат через image.RegisterFormat, но для PNG, JPEG и GIF стандартных декодеров хватает.

Почему у GeoM координаты float64, если пиксели целые?

Матрица обслуживает и позиции, и повороты со масштабами, где дроби неизбежны. Дробные позиции накапливают движение без ошибок округления, а приведение к пикселям происходит на этапе растеризации — это живёт в пятом уроке про плавное движение. Практическое следствие: не удивляйтесь, что GeoM.Rotate принимает угол в радианах — конверсию из привычных градусов вы делаете сами, множителем math.Pi / 180.

Термины урока

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

Следующая ступень учебного пути

Chromedp25 уроков~11 чпрактика25 уроков уже доступны

Chromedp в Go: автоматизация Chrome без Selenium

Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.

  • Запускать Chrome из Go и управлять страницами
  • Находить элементы селекторами и ждать их появления
  • Кликать, заполнять формы и извлекать данные
  • Делать скриншоты, PDF и выполнять JavaScript