Содержание урока · 10 разделов
- ebitengine спрайт: всё есть ebiten.Image
- Загрузка PNG: embed и image.Decode
- ebiten drawimage: позиция решает GeoM
- Прозрачность PNG и альфа-блендинг
- Фильтры и батчи: FilterNearest против FilterLinear
- DrawImageOptions: поле за полем
- Шесть ошибок первых спрайтов одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Чёрное окно из первых уроков честно тикает, но игроку от этого не весело: в 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
GOpackage 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 копирует пиксели аргумента в момент вызова: если вы измените картинку после отрисовки, уже нарисованный кадр не пострадает — композиция кадра идёт от простых к сложным слоям.
Кадр: фон плюс герой в углу
GOfunc (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: поле за полем#
| Поле | Значение по умолчанию | Для чего |
|---|---|---|
| GeoM | identity — рисует в (0, 0) | позиция, масштаб, поворот спрайта |
| ColorScale | identity (1, 1, 1, 1) | окраска и альфа: ScaleAlpha для полупрозрачности |
| Blend | обычный альфа-блендинг | способ наложения источника на фон |
| Filter | FilterNearest | фильтр при растяжении: nearest для пиксель-арта, linear для гладкой графики |
Какой фильтр используется по умолчанию и почему он удачен для пиксель-арта?
screen.DrawImage(screen, op) — гарантированная паника: документация прямо запрещает рисовать картинку в саму себя. Тот же исход у disposed-спрайта в роли аргумента. Что сломалось бы, разреши движок такое: пиксели читались бы и писались одновременно, и результат зависел бы от порядка обработки строк. Хотите эффект «старый кадр поверх нового» — копируйте экран в offscreen-буфер NewImage и рисуйте уже буфер.
Загрузку PNG и DrawImage в браузерной песочнице не повторить: нужен локальный Go, окно и настоящая видеокарта. Зато координатная арифметика спрайтов — Translate, границы Bounds, расчёт углов — чистая математика на срезах, и тематическая программа в песочнице сайта пересчитывает её прямо на странице. Локальный проект с героем из PNG соберите по коду этого урока.
Шесть ошибок первых спрайтов одним списком#
- image: unknown format — забыли пустой импорт _ "image/png"
- Спрайт прилип к углу — забыли op.GeoM.Translate
- Второй спрайт съехал дальше — Translate накопился, нужен GeoM.Reset()
- Белая рамка вокруг героя — PNG без альфа-канала
- Паника при отрисовке — рисуете screen в сам screen или используете disposed-спрайт
- Мыло вместо пикселей — кто-то включил FilterLinear для пиксель-арта
Соберите отрисовку спрайта
Восстановите три пропуска в пути спрайта: опции, матрица и вызов.
Путь спрайта от файла до экрана
Расставьте шаги загрузки и отрисовки спрайта по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
У вас теперь есть полный конвейер: PNG через embed, image.Decode с пустым импортом, NewImageFromImage и DrawImage с GeoM — этого хватает для любого статичного кадра. Спрайт — только начало: те же опции DrawImage обслужат и тайлы, и частицы, и кадры анимации, поэтому конвейер этого урока вы будете переиспользовать до самого итогового проекта. Следующий шаг очевиден: заставить координаты меняться 60 раз в секунду, и тут всплывает разница тиков и кадров, которую мы разбираем в уроке про движение и дельту. По мере роста числа картинок появится вопрос организации: вырезание кусочков из общего листа разберёт урок про атласы, а аккуратные пивоты и границы спрайта — урок про прямоугольники (08-pryamougolniki). До встречи там: возьмите любой PNG с альфой, поставьте героя в четыре угла холста и убедитесь, что predict про накопление Translate вы разгадали не зря.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 произвольный прямоугольник и нарисуйте его отдельно — пригодится при переходе к атласам.
Личные заметкиПусто
Вопросы и ответы по уроку
Спрайт рисуется, но с белой рамкой вокруг героя — в чём дело?
Рамка почти всегда означает PNG без альфа-канала: фон картинки непрозрачный белый, и прозрачность блендить не из чего. Пересохраните файл с прозрачностью, а затем проверьте, что рисуете именно декодированный PNG, а не промежуточную копию без альфы.
Обязательно ли хранить ассеты через embed?
Нет: можно читать файлы с диска через os.ReadFile — удобно на стадии разработки, когда картинки меняются часто. Embed выигрывает в релизе: один бинарник без раздаточных папок, а оба пути сходятся в один и тот же image.Decode. Третий путь — зарегистрировать собственный формат через image.RegisterFormat, но для PNG, JPEG и GIF стандартных декодеров хватает.
Почему у GeoM координаты float64, если пиксели целые?
Матрица обслуживает и позиции, и повороты со масштабами, где дроби неизбежны. Дробные позиции накапливают движение без ошибок округления, а приведение к пикселям происходит на этапе растеризации — это живёт в пятом уроке про плавное движение. Практическое следствие: не удивляйтесь, что GeoM.Rotate принимает угол в радианах — конверсию из привычных градусов вы делаете сами, множителем math.Pi / 180.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript