---
title: "Частицы и эффекты"
description: "Взрывы и следы: срез частиц с временем жизни. Частицы игры на Ebitengine: эффекты с альфой по остатку жизни, пул без аллокаций, гравитация и drag в Update."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/17-chasticy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Частицы и эффекты

*Курс «Ebitengine: 2D-игры на Go», урок 17 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/17-chasticy/*

Частицы — это сотни крошечных объектов с короткой жизнью, из которых собираются взрывы, следы и искры. Движок не знает про них ни слова: частицы — обычный срез структур, обновляемый в Update и рисуемый в Draw. Соберём эффекты ebitengine на срезе: struct с временем жизни, эмиссия по событиям, альфа по остатку жизни и пул переиспользования, который не тревожит сборщик мусора.

Пуля попала во врага — и за один кадр на экране должен разлететься взрыв, а герой обязан оставлять за собой след. Спрайтами такое не делают: картинок на каждый случай не напасёшься. Частицы решают вопрос математикой: маленький прямоугольник с позицией, скоростью и временем жизни, размноженный два-три десятка раз со случайным разбросом, глаз читает как взрыв. Частицы игры — не библиотека и не подсистема движка, а обычный срез ваших структур: Ebitengine даёт тик и холст, всё остальное — цикл по срезу. Урок собирает полный мини-эмиттер: рождение частиц по событию из урока про [коллизии](/courses/ebitengine/lessons/09-kollizii-aabb/), обновление с гравитацией и сопротивлением, отрисовку с затуханием альфы и пул, после которого эффекты перестают стоить аллокаций. Дешевизна — главный аргумент: частица не тянет ни текстуры, ни коллизий, ни кадров анимации, а мощность эффекта масштабируется числом записей в срезе.

### Частица: struct с временем жизни

Каждая частица — запись из шести полей: позиция x и y, скорость vx и vy, остаток жизни life и полный запас ttl. Числа берутся float32: ровно такие ждут функции пакета vector, и конвертации в горячем цикле не нужны. Цвет — color.NRGBA со всеми четырьмя каналами: альфа понадобится для затухания. Позиции и скорости — дробные: целые пиксели дали бы дёрганый рой, а float32 хранит доли без лишней памяти. Пара life и ttl — сердце модели: при рождении они равны, каждый тик логики уменьшает life, а отношение life к ttl даёт долю оставшейся жизни — из неё считаются и прозрачность, и размер. Формулы движения — те же, что в уроке про [движение и дельту](/courses/ebitengine/lessons/05-geometriya-dvizheniya/), только скорость здесь меняется ещё и сама: множитель-сопротивление тормозит частицу, гравитация тянет вниз.

Отдельных типов «частица-взрыв» и «частица-дым» заводить не нужно: различие живёт в стартовых параметрах — скорости, цвете, ttl. Одна структура, разные константы рождения: так таблица параметров в конце урока описывает пять эффектов без единого нового типа. ttl — константа рождения, и хранить его рядом с life выгодно: не нужен параллельный массив с параметрами, а формула альфы остаётся локальной парой полей.

### Структура частицы и взрыв

```go
type Particle struct {
        x, y   float32
        vx, vy float32
        life   float32 // остаток жизни, тиков
        ttl    float32 // полный запас жизни
        clr    color.NRGBA
}

func (g *Game) spawnExplosion(x, y float32) {
        for i := 0; i < 24; i++ {
                angle := rand.Float64() * 2 * math.Pi
                speed := rand.Float64()*3 + 1 // от 1 до 4
                g.parts = append(g.parts, Particle{
                        x: x, y: y,
                        vx:  float32(math.Cos(angle) * speed),
                        vy:  float32(math.Sin(angle) * speed),
                        life: 45, ttl: 45,
                        clr: color.NRGBA{R: 255, G: 160, B: 40, A: 255},
                })
        }
}
```

Частица — маленькая структура из шести полей: позиция, скорость, остаток жизни, полный запас и цвет. ttl равен life при рождении, дальше life тает, и отношение life к ttl — доля оставшейся жизни для альфы. Круговой разлёт считается из случайного угла: косинус и синус переводят его в vx и vy, скорость берётся в диапазоне. append растит срез по мере надобности — на прототипе нормально, пул появится ниже.

**Вопрос:** Что происходит с частицей, когда life доходит до нуля?

- Навсегда остаётся в срезе с нулевой альфой
- ✅ Убирается из среза при обновлении и перестаёт существовать
- Движок сам удаляет её из памяти по таймеру
- Телепортируется в начало среза для переиспользования

Жизнь частицы ведёт ваш код: движку о частицах известно ровно столько, сколько вы нарисовали. Мёртвые записи отсеиваются в updateParticles — иначе срез растёт бесконечно, а Draw тратит время на невидимые пиксели. Нулевая альфа не бесплатна: прямоугольник всё равно уходит в отрисовку, и в итоге на кадр приходится сотни пустых вызовов.

### Эмиссия: взрыв и след героя

Рождение частиц — событие, а не процесс: столкновение снаряда с врагом зовёт spawnExplosion, и за один вызов срез пополняют двадцать-тридцать записей. Круговой разлёт получается из случайного угла: rand.Float64 умножается на два пи, скорость выбирается в диапазоне от минимума до максимума, косинус и синус переводят угол в vx и vy. Для следа героя всё скромнее: одна-две частицы за тик в точке кормы, малая скорость, короткая жизнь.

Источник случайности — math/rand/v2: rand.Float64 даёт дробь от нуля до единицы, rand.IntN — целое в диапазоне, и сидировать генератор не нужно. Правило эмиссии то же, что у всего состояния прошлого урока: спавнит только Update. Диапазоны скоростей задавайте базой и шириной разброса, а не парой магических чисел: поменять ширину — то же самое, что поменять характер эффекта. При роях на много врагов держите бюджет: двадцать частиц на эффект и не больше сотни рождений за тик. Draw, встретив пустой срез, честно ничего не рисует; если взрыв случился — он случился в тике логики, и никакая отрисовка не порождает объекты задним числом.

### Обновление: drag, гравитация, жизнь

```go
func (g *Game) updateParticles() {
        alive := g.parts[:0]
        for i := range g.parts {
                p := &g.parts[i]
                p.vx *= 0.95 // drag: сопротивление воздуха
                p.vy += 0.15 // гравитация тянет вниз
                p.x += p.vx
                p.y += p.vy
                p.life--
                if p.life > 0 {
                        alive = append(alive, *p)
                }
        }
        g.parts = alive
}
```

Обновление живёт в Update и только там: скорость тормозится множителем, гравитация подтягивает по вертикали, позиция растёт на скорость — геометрия движения пятого урока, применённая к рою. Мёртвые частицы отсеиваются компоновкой на месте: срез parts переиспользуется как буфер, и аллокаций нет. Мёртвая частица исчезает целиком — рисовать её с нулевой жизнью не нужно.

### Траектория искры

```go
// При рождении: vx = 0.5, vy = -3 (вверх)
// Update каждый тик:
p.vx *= 0.95 // drag
p.vy += 0.15 // гравитация
p.x += p.vx
p.y += p.vy
```

**Что выведет программа?**

- Прямую вверх: drag и гравитация не меняют направление
- ✅ Параболу: подъём, замирание на вершине и падение с разгоном
- Вечное кружение на месте вокруг точки рождения
- Мгновенную остановку: скорость обнуляется в первый тик

Гравитация каждый тик добавляет 0.15 к vy: отрицательная скорость тает, обнуляется и становится положительной — частица замирает на вершине и падает с разгоном. Drag параллельно гасит горизонталь, поэтому след изгибается в дугу. Это формулы движения из пятого урока, применённые к рою: одна частица ведёт себя как обычное тело.

### Рендер: альфа по остатку жизни

Рисование частиц — самый дешёвый код урока: цикл по срезу, счёт альфы, один вызов vector. Доля жизни frac := p.life / p.ttl умножается на 255 и обрезается до uint8 — цвет частицы тает вместе с ней: свежий взрыв плотный оранжевый, умирающий прозрачный. Цвет копируется из записи перед изменением альфы: color.NRGBA — значение, и исходная частица среза не портится. Форма — прямоугольник vector.DrawFilledRect три на три пикселя или круг vector.DrawFilledCircle: для десятков частиц сглаживание выключают, antialias false, потому что каждая истина стоит лишнего прохода.

Размер — второй параметр эффекта: искры три на три, дым восемь на восемь с низкой альфой, и варьируется он константой рядом с ttl, без новых полей. Координаты частиц — мировые: если мир скроллится, применяйте камеру из урока про [камеру](/courses/ebitengine/lessons/13-kamera/) так же, как для спрайтов, — иначе взрыв останется висеть в углу экрана, пока мир уезжает. Частица не знает про камеру ничего: это свойство отрисовки, а не данных. Здесь работает тот же закон прошлого урока: Draw читает срез, но не пишет ни в одно поле — частицы в кадре не рождаются и не умирают. Затухание тут не зависит от частоты кадров: доля жизни считается из тиков логики, поэтому эффект тает одинаково на 60 и на 144 герцах.

### drawParticles: прямоугольники, тающие с жизнью

```go
func (g *Game) drawParticles(screen *ebiten.Image) {
        for i := range g.parts {
                p := &g.parts[i]
                frac := p.life / p.ttl
                clr := p.clr
                clr.A = uint8(255 * frac)
                vector.DrawFilledRect(screen, p.x, p.y, 3, 3, clr, false)
        }
}
```

Draw только читает срез: одна итерация, никаких изменений. Альфа считается из доли жизни, uint8 принимает 0..255, поэтому 255 умножается на дробь и обрезается. Цвет копируется, чтобы не портить исходную запись среза: color.NRGBA — значение. Прямоугольники три на три без сглаживания — частиц много, и каждая обязана быть дешёвой; для круглых искр есть DrawFilledCircle.

### Эффект и его параметры рождения

| Эффект | Частиц | Скорость | ttl, тиков | Гравитация |
| --- | --- | --- | --- | --- |
| Взрыв врага | 20-30 | 1-4, круговой | 30-60 | 0.1-0.2, оранжевый |
| След героя | 1-2 за тик | 0.1-0.3 | 15-25 | 0, цвет героя |
| Искры удара | 5-8 | 2-6, веером | 10-20 | 0.3, жёлтый |
| Дым двигателя | 1 за тик | 0.2 против движения | 40-80 | минус 0.05, вверх |
| Попадание в героя | 15 | 2-5 | 25 | 0.15, красный |

**Вопрос:** Где правильно порождать новые частицы взрыва?

- В Draw героя, чтобы взрыв появился в том же кадре
- ✅ В Update, при обнаружении столкновения снаряда с врагом
- В main, в отдельной горутине по таймеру
- В Layout, пока окно пересчитывает размеры

Столкновение обнаружено в Update — там же и рождаются частицы: эмиссия подчиняется общему правилу состояния, один автор изменений. Взрыв из Draw зависел бы от частоты кадров и исчезал бы в свёрнутом окне; горутина вне цикла напрашивается на гонку со срезом частиц, а Layout знает только размеры окна.

### Пул частиц: фиксированный срез без аллокаций

append на прототипе честен, но у него есть цена: рост среза аллоцирует новый массив, старый уходит сборщику мусора, и на сотнях частиц за тик GC напоминает о себе паузами. Решение — пул: фиксированный срез на максимум частиц, например тысячу, и индекс живого диапазона. Мёртвые записи не удаляются, а переиспользуются: спавн берёт следующую ячейку и перезаписывает её поля, компоновка живых делается обменом с последним элементом диапазона. Аллокаций ноль после старта — срез создан один раз, и сборщику мусора нечего забирать. Реализация пула начинается с make на максимум частиц при старте игры: ёмкость выделена один раз, и дальше ни один спавн не трогает аллокатор; спавн пишет в следующую ячейку живого диапазона, а отсеивание обменивает мёртвую ячейку с последней живой и укорачивает диапазон — обе операции это присваивание пары полей.

Компромисс пула — потолок: частиц больше максимума не бывает, и spawnExplosion при переполнении либо пропускает рождение, либо перезаписывает самую старую запись. Для аркады потолок в тысячу — не ограничение, а защита: даже залипшая на коллизии пара не съест память. Подробный разбор стоимости аллокаций и пауз ждёт в уроке про [производительность](/courses/ebitengine/lessons/20-proizvoditelnost/) — там же профилировщик покажет, сколько выигрывает пул. Пока запомните правило: горячий цикл без аллокаций — это фиксированный срез, а не append.

> **Живое место: append в горячем цикле.** Симптом легко пропустить: на машине разработчика всё летает, а на слабом ноутбуке раз в несколько секунд кадр дёргается — сборщик мусора собирает тысячу умерших массивов. Причина — append к срезу частиц каждый тик без ёмкости: рост, копирование, мусор. Второй симптом — вечная частица: мёртвые записи забыли отсеять, срез растёт до бесконечности, Draw честно рисует невидимое. Лечится оба раза одинаково: фиксированный пул, компоновка на месте, длина среза равна числу живых. Третий симптом тише всех: память в диспетчере задач растёт между двумя взрывами — срез не вернулся к прежней длине, значит, где-то пережили мёртвые записи.

### Правила частиц одним списком

- Частица — struct из позиции, скорости, life, ttl и цвета color.NRGBA
- life рождается равным ttl и тает на тик; доля life к ttl даёт альфу
- Спавн — событие в Update: столкновение, выстрел, след за движением
- Случайный разлёт — угол rand.Float64 на два пи и скорость в диапазоне
- Update двигает: drag множителем, гравитация слагаемым, позиция плюс скорость
- Draw только читает: альфа по остатку жизни, без изменений среза
- Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций в тике
- Параметры эффекта — константы рождения: скорость, ttl, гравитация, цвет

### Соберите отрисовку частиц

Восстановите формулу альфы и имя векторной функции.

```go
func (g *Game) drawParticles(screen *ebiten.Image) {
        for i := range g.parts {
                p := &g.parts[i]
                frac := p.life / p.ttl
                clr := p.clr
                clr.A = uint8(255 * ___)
                vector.___(screen, p.x, p.y, 3, 3, clr, false)
        }
}
```

Подсказка: Альфа — доля оставшейся жизни; заполненный прямоугольник рисует пакет vector.

Правильные ответы в пропусках: `frac`, `DrawFilledRect`

> **Про песочницу: частицы без окна.** Рисование в окно и пакет vector доступны только в локальном Go — в песочнице сайта окна нет. Зато логика частиц — чистая математика над срезом: тематическая программа урока крутит updateParticles в песочнице и печатает по тикам позиции и остатки жизни — видно, как drag тормозит рой и гравитация тянет дугу. Перенос на экран — пара вызовов из этого урока в вашем проекте.

### Тик обновления частиц

Соберите updateParticles: физика и жизнь.

Правильный порядок строк:

1. `func (g *Game) updateParticles() {`
2. `for i := range g.parts {`
3. `p := &g.parts[i]`
4. `p.vx *= 0.95 // drag: горизонтальная скорость тает`
5. `p.vy += 0.15 // гравитация добавляется к вертикальной`
6. `p.x += p.vx`
7. `p.y += p.vy`
8. `p.life-- // каждый тик съедает часть жизни`
9. `} // конец цикла по срезу`
10. `} // конец updateParticles`

### Баланс и отладка эффектов

Числа из таблицы — стартовые, а финальные подбираются игрой: у взрыва должно быть время дочитаться, у следа — не замылить экран. Два ползунка дают девяносто результата: ttl и число частиц. Много короткоживущих частиц читаются как искра, мало долгоживущих — как дым; сбалансированный взрыв — среднее между ними с большим разбросом скорости. Отладке помогает счётчик живых частиц в углу HUD: если после десяти взрывов число не вернулось к нулю, где-то живёт вечная частица — ищите пропуск уменьшения life или отказ от отсеивания.

Эффекты подчиняются геймплею, а не наоборот: взрыв, закрывающий врага на полсекунды, стоит игроку жизни. Частицы — украшение поверх честного состояния из прошлого урока: очки начислены в Update, частицы только празднуют событие. Случайность не обязана быть воспроизводимой: если захочется повторных взрывов один в один — сидируйте генератор на эффект, но для аркады чистый rand честнее и проще. Полезна и отладочная клавиша, рожающая взрыв под курсором: баланс превращается из правок кода в игру, а замороженный ttl делает траектории видимыми линиями, по которым читаются и drag, и гравитация.

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

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

- **Частица** — Маленькая запись с позицией, скоростью, life, ttl и цветом; из роя таких собираются взрывы, следы и искры.
- **ttl и life** — Полный запас жизни и её остаток: при рождении равны, тик уменьшает life; доля life к ttl даёт альфу.
- **Эмиссия** — Рождение частиц по событию в Update: взрыв — десятки за вызов, след — одна-две за тик.
- **Drag** — Сопротивление среды: скорость умножается на коэффициент меньше единицы каждый тик и постепенно тает.
- **Альфа по жизни** — clr.A = uint8(255 умножить на life к ttl): свежая частица плотная, умирающая прозрачная.
- **Пул частиц** — Фиксированный срез с переиспользованием ячеек: ноль аллокаций в тике и нет пауз сборщика мусора.

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

- Частица — struct с позицией, скоростью, life, ttl и цветом; различие эффектов — параметры рождения.
- Спавн — событие в Update: взрыв за один вызов, след — пара частиц за тик.
- Update двигает частицы: drag тормозит множителем, гравитация тянет слагаемым, life тает на тик.
- Альфа считается из доли life к ttl: цвет тает вместе с частицей.
- Draw только читает срез: частицы не рождаются и не умирают в отрисовке.
- Пул — фиксированный срез с переиспользованием ячеек: ноль аллокаций, сборщик мусора молчит.
- Таблица параметров описывает взрыв, след, искры и дым одной структурой без новых типов.

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

Добавьте в проект срез частиц и структуру Particle из урока. Повесьте spawnExplosion на попадание снаряда во врага из урока про коллизии: двадцать частиц, скорость от единицы до четырёх, ttl от тридцати до шестидесяти. Сделайте след героя: одна частица за тик позади кормы при движении, ttl пятнадцать, гравитация ноль. В Draw рисуйте прямоугольники с альфой по доле жизни и следите за счётчиком живых — после серии взрывов он обязан возвращаться к нулю. Затем замените append фиксированным пулом на тысячу ячеек с компоновкой живых на месте и сравните поведение на серии из сотни взрывов. В конце подберите параметры под таблицу урока: каждый эффект должен отличаться от соседнего на глаз, без подписей.

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

### Почему float32, а не float64?

Пакет vector принимает координаты float32: DrawFilledRect и DrawFilledCircle ждут именно его, и конвертации в каждом кадре не нужны. Точности float32 для частиц хватает с запасом: значения порядка сотен пикселей с долями дают погрешность в копейки за всю короткую жизнь частицы. float64 оставьте большим мирам и камере, где накопление ошибки заметнее.

### Как сделать взрыв объёмным?

Два слоя частиц с разными параметрами: быстрое яркое ядро с коротким ttl и медленный тёмный дым с длинным. Второй приём — размер вместо альфы: частица растёт и бледнеет одновременно, глаз читает это как остывающий газ. Оба варианта не добавляют новых типов — только вторую горсть констант рождения в тот же spawnExplosion. Третий слой — composite-режимы смешивания из урока про примитивы: аддитивное свечение красиво, но это опция, а не необходимость первого эмиттера.

### Нужны ли частицам тесты?

Логике — да: спавн и обновление — чистые функции над срезом, и тест без окна проверяет, что взрыв рождает заявленное число частиц, а после ttl тиков срез пуст. Рендер тестировать бессмысленно: это цикл по срезу и один вызов vector. Пул заслуживает отдельного теста на переполнение: породите больше максимума и убедитесь, что игра не падает, а старые ячейки переиспользуются.
