LeCodesdocs

Particles

GPU-система частиц как узел. Добавь её в сцену — и она стримит частицы: дым, искры, пыль. Расширяет Node: двигай/вращай узел, чтобы двигать эмиттер (частицы симулируются в локальном пространстве эмиттера). Каждая опция также живой сеттер, поэтому rate, color, gravity, … можно менять в рантайме.

Частицы рисуются материалом частиц по умолчанию (Material.particles() — точечные спрайты, повёрнутые к камере; мягкие круглые точки, пока не дашь текстуру), если не передать свой скомпилированный шейдер через material.

Обзор

TypeScript
const sparks = new Particles({
  blend: 'add',                                                 // аддитивный — искры светятся
  rate: 80,                                                     // частиц/секунду
  shape: { type: 'point', v: [0, 0, 0] },
  startVelocity: { dir: [0, 1, 0], speed: { min: 2, max: 4 }, spread: 0.3 },
  gravity: 9.8,                                                 // положительное тянет ВНИЗ
  lifetime: { min: 0.6, max: 1.2 },
  size: { min: 0.05, max: 0.12 },
  color: colorCurve('#ffcc44').via(0.7, '#ffffff').to('#00000000'),  // затухание за время жизни
})
sparks.position = [0, 1, 0]
scene.add(sparks)

crate.addEventListener('click', () => sparks.spawn(40))         // всплеск поверх rate

Текстурный флипбук-эффект (лист спрайтов с кадрами):

TypeScript
const fire = new Particles({
  map: await Texture.load(asset('./flame-sheet.png')),
  sheet: 8,                    // 8×8 кадров; автоцикл один раз за жизнь частицы
  blend: 'add',
  emissive: 1.5,               // дополнительное свечение
  rate: 2,
  lifetime: 3,
  size: { min: 1.2, max: 2 },
})

По умолчанию каждая частица начинает цикл со случайного кадра — циклы вроде огня или дыма остаются рассинхронизированными. Для листа, который рассказывает историю по порядку (секвенция взрыва), начинай с 0; для неквадратного листа задай сетку как [cols, rows]:

TypeScript
const boom = new Particles({
  map: await Texture.load(asset('./explosion.png')),
  sheet: [8, 4],               // 8 столбцов × 4 строки = 32 кадра…
  startFrame: 0,               // …проигрываются по порядку, один раз за жизнь частицы
  rate: 0,
  lifetime: 0.8,
})
boom.spawn(1)

Опции

TypeScript
new Particles(options?: {
  // --- внешний вид (материал по умолчанию; см. Material.particles в material.md) ---
  map?: Texture                            // текстура спрайта; не задана = мягкая круглая точка
  sheet?: number | [cols, rows]            // сетка флипбука: N = N×N, или явно cols×rows (по умолчанию 1)
  startFrame?: number | 'random'           // с чего начинается дефолтный цикл флипбука (по умолчанию 'random')
  emissive?: number                        // дополнительная яркость для свечения (по умолчанию 0)
  blend?: 'alpha' | 'add'                  // смешивание: дым/пыль vs огонь/искры (по умолчанию 'alpha')
  soft?: boolean                           // радиальное затухание; по умолчанию: вкл без map, выкл с ней
  render?: 'point' | 'quad' | 'stretch'    // точечные спрайты (по умолчанию) / настоящие квады / квады, растянутые по скорости
  stretch?: number                         // ('stretch') доп. длина на единицу скорости (по умолчанию 0.05)
  material?: Material                      // свой скомпилированный шейдер — опции вида выше игнорируются

  // --- эмиттер ---
  maxParticles?: number                    // ёмкость пула частиц (по умолчанию 1000)
  rate?: number                            // частиц в секунду
  space?: 'local' | 'world'                // 'world': частицы остаются там, где родились, пока эмиттер движется (по умолчанию 'local')
  inheritVelocity?: number                 // доля скорости эмиттера, передаваемая частицам при спавне
  rateOverDistance?: number                // доп. частицы на мировую единицу ДВИЖЕНИЯ эмиттера (см. ниже)
  shape?: { type: 'point', v: Vec3Like }   // спавн со смещением (относительно эмиттера)
        | { type: 'box', min: Vec3Like, max: Vec3Like }        // случайно внутри бокса
        | { type: 'circle', center: Vec3Like, radius: number } // случайно на диске в плоскости XZ
  startVelocity?: Vec3Like | VelocityValue // см. ниже
  gravity?: number                         // мировые единицы/с² — ПОЛОЖИТЕЛЬНОЕ ТЯНЕТ ВНИЗ (сахар для acceleration: [0, -g, 0])
  acceleration?: Vec3Like                  // постоянный вектор ускорения (гравитация, ветер, восходящий поток…)
  drag?: number                            // затухание скорости — выше тормозит частицы быстрее
  lifetime?: number | { min, max }         // секунды; диапазон = случайно на частицу
  color?: ColorInput | { min, max } | colorCurve(…)  // константа, случайно между двумя цветами или кривая
  size?: number | { min, max } | curve(…)  // диаметр в мировых единицах (алиас custom[0]); по умолчанию 1
  rotation?: …                             // радианы (алиас custom[1])
  opacity?: …                              // 0..1 (алиас custom[2]); по умолчанию 1
  frame?: …                                // индекс кадра флипбука (алиас custom[3])
  noise?: { strength?, frequency?, speed? } | null   // турбулентность; все поля по умолчанию 1
  seed?: number                            // фиксированный сид рандома — детерминированные тесты
})

У всего есть дефолт — new Particles() уже эмитит мягкие белые точки. Поля типа number | { min, max } принимают плоское значение («всегда это») или диапазон («случайно на частицу»).

startVelocity

Простой Vec3Like — фиксированная скорость запуска (её длина — это speed); объект выбирает режим направления плюс опциональную рандомизацию:

TypeScript
startVelocity: {
  dir?: Vec3Like                                     // запуск вдоль этого направления, или…
  from?: Vec3Like                                    // …прочь от этой точки, или…
  to?: Vec3Like                                      // …к этой точке
  speed?: number | { min: number, max: number }      // масштабирует направление; диапазон = на частицу
  spread?: number                                    // полуугол конуса (радианы) — дрожание в его пределах
  randomizeAngle?: { min, max }                      // асимметричное дрожание: два угла (x/y), радианы
}
TypeScript
sparks.startVelocity = { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.25 }

Движущиеся эмиттеры — шлейфы

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

  • rateOverDistance эмитит на мировую единицу пройденного пути (поверх rate), с точками спавна, равномерно распределёнными вдоль пути — плотность шлейфа не зависит от скорости, без покадровых комков.
  • inheritVelocity отдаёт каждой частице долю скорости эмиттера при спавне, чтобы дым выглядел сорванным с движущейся машины, а не возникшим из ниоткуда.
TypeScript
// дым дрифта у колеса: прикрепи к узлу колеса и просто езжай
const smoke = new Particles({
  map: smokeTex,
  sheet: 8,
  space: 'world',
  rate: 0,
  rateOverDistance: 12,            // 12 клубов на метр дрифта, на любой скорости
  inheritVelocity: 0.4,            // уносится вместе, потом остаётся позади
  shape: { type: 'circle', center: [0, 0, 0], radius: 0.15 },
  startVelocity: { dir: [0, 1, 0], speed: 0.5, spread: 0.6 },
  lifetime: { min: 1, max: 2 },
  size: curve({ min: 0.4, max: 0.7 }).to(2),
  opacity: curve(0.5).fade(0.1, 0.5),
})
wheel.add(smoke)

// модулируй количество из геймплея — оба живые сеттеры:
smoke.rateOverDistance = 12 * driftAmount

Переключение space в рантайме сбрасывает живые частицы (они хранятся в старом пространстве). Оба компаньона имеют смысл только со space: 'world' — в локальном пространстве эмиттер никогда не «движется» относительно своих частиц.

Режимы отрисовки

  • 'point' (по умолчанию) — GPU-спрайты точек: самый дешёвый режим на каждом бэкенде, но драйверы ограничивают максимальный экранный размер (экстремальные крупные планы упираются в потолок), а вращение обрезает углы спрайта.
  • 'quad' — настоящие квады, повёрнутые к камере: без ограничения размера, честное геометрическое вращение. Чуть больше CPU/загрузки (4 вершины на частицу).
  • 'stretch' — квады, растянутые вдоль скорости каждой частицы, в проекции на экран: искры, дождь, полосы скорости. stretch добавляет stretch · |velocity| мировых единиц длины поверх size.
TypeScript
const sparks = new Particles({
  render: 'stretch',
  stretch: 0.08,                     // искра на 4 ед/с рисуется ~на 0.3 м.е. длиннее своей ширины
  blend: 'add',
  startVelocity: { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.4 },
  gravity: 9.8,
  size: 0.05,
  lifetime: { min: 0.4, max: 0.9 },
})

Ленты — Trail

Trail — не эмиттер, а лента, следующая за своим узлом по миру: дуга взмаха меча, следы шин, шлейфы ракет. Двигай узел (или что угодно, чему он дочерний): лента кладёт точку через каждые minDistance движения, каждая точка живёт time секунд, а головой лента остаётся приклеенной к узлу.

TypeScript
const slash = new Trail({
  time: 0.25,                        // как долго лента держится
  width: curve(0.3).to(0),           // полная ширина у клинка, сужается в ничто к хвосту
  color: '#8df0ff',
  blend: 'add',
})
swordTip.add(slash)
// взмахни мечом — дуга появится сама; между взмахами перестань класть точки:
slash.emitting = false               // существующие точки всё равно стареют, так что дуга гаснет естественно

width / opacity / color принимают те же кривые, что и Particles, вычисляемые по жизни каждой точки — то есть вдоль ленты от головы (свежая) к хвосту (умирающая). Дефолтная opacity уже гасит хвост. Живые сеттеры: time, width, opacity, color, minDistance, emitting. Опции также принимают общие поля вида (map/sheet/emissive/blend/soft); ось u у map идёт вдоль ленты, голова → хвост.

Кривые за время жизни частицы — curve() / colorCurve()

size, rotation, opacity, frame и color могут анимироваться за время жизни каждой частицы. Кривая читается как путь: .from(v) при рождении → точки .via(t, v).to(v) при смерти; t идёт 0..1 за жизнь частицы.

TypeScript
curve(base?: number | { min, max },        // значение частицы (диапазон = случайно на частицу)
      mode?: 'multiply' | 'add')           // кривая умножает базу (по умолчанию) или прибавляет к ней
  .from(v)                                 // значение при t = 0 — должно идти первым
  .via(t, v)                               // значение при t (по возрастанию)
  .to(v)                                   // значение при t = 1
  .fade(in?, out?)                         // классический конверт: рост за `in`, спад за последние `out`

colorCurve(base?: ColorInput | { min, max })   // всегда умножает базовый цвет
  .from(c).via(t, c).to(c)

Любое значение стопа может быть диапазоном { min, max } — рандомизируется на частицу. Цепочка без .from начинается с нейтрального значения (1 для multiply, 0 для add); без .to держит последнее значение.

TypeScript
sparks.size = curve(0.1).to(4)                        // вырасти до 4× к концу жизни
sparks.size = curve(0.3).to(0)                        // сжаться в ничто
sparks.opacity = curve(0.3).fade(0.15, 0.4)           // = .from(0).via(0.15,1).via(0.6,1).to(0)
sparks.rotation = curve({ min: 0, max: 6.28 }, 'add').to({ min: -2, max: 2 })   // вращение ±2 рад за жизнь
sparks.color = colorCurve('#ffaa33').via(0.7, '#ffffff').to('#000000')          // затухание в чёрный

colorCurve умножает базу, поэтому его главное применение — затухание яркости/альфы к смерти. Держи стопов мало (максимум 8), а t — по возрастанию: они запекаются в короткую нативную кривую, а не в произвольный сплайн; невалидная кривая отклоняется с предупреждением в консоли, прежняя остаётся.

Живые сеттеры и методы

TypeScript
sparks.spawn(count): this      // эмитировать всплеск прямо сейчас, поверх `rate` — чейнится
sparks.material                // get/set — сменить материал отрисовки на лету

// живые сеттеры только-для-записи (те же типы, что опции):
sparks.rate = 120
sparks.space = 'world'                 // переключение сбрасывает живые частицы
sparks.inheritVelocity = 0.5
sparks.rateOverDistance = 12
sparks.shape = { type: 'box', min: [-1, 0, -1], max: [1, 0, 1] }
sparks.startVelocity = [0, 3, 0]
sparks.gravity = 9.8                   // положительное = вниз
sparks.acceleration = [0.5, 0, 0]      // ветер
sparks.drag = 0.5
sparks.lifetime = { min: 0.5, max: 1 }
sparks.color = '#88ccff'
sparks.size = curve(0.1).to(4)
sparks.rotation = { min: 0, max: 6.28 }
sparks.noise = { strength: 2 }         // или null, чтобы отключить

sparks.opacity = curve().fade(0.2)     // появление и затухание
sparks.frame = curve({ min: 0, max: 63 }, 'add').to(128)  // флипбук: 2 цикла за жизнь

sparks.custom[0] = 0.2                 // сырые слоты параметров кривой 0..3; size/rotation/opacity/frame — алиасы 0–3

Жизненный цикл: система эмитит непрерывно с конструирования; sparks.destroy() (из Node) убирает её и освобождает её GPU-буферы. Для одноразового эффекта поставь rate: 0 и вызывай spawn(n).

Подводные камни

TypeScript
// ✗ покадровый spawn() для имитации частоты эмиссии
setLoop(() => sparks.spawn(1))
// ✓ для этого есть rate
sparks.rate = 60

// ✗ ждать, что curve(2).to(4) анимирует 2 → 4
sparks.size = curve(2).to(4)         // база 2 × кривая(1 → 4): анимирует 2 → 8
// ✓ база — значение частицы; сам путь клади в кривую
sparks.size = curve().from(2).to(4)  // анимирует 2 → 4

Смотрите также

  • MaterialMaterial.particles({...}) (материал отрисовки по умолчанию) и свои шейдеры
  • Node — трансформ (эмиттер), destroy()
  • Scene — где живёт система