Particles
GPU-система частиц как узел. Добавь её в сцену — и она стримит частицы: дым,
искры, пыль. Расширяет Node: двигай/вращай узел, чтобы двигать эмиттер
(частицы симулируются в локальном пространстве эмиттера). Каждая опция также живой сеттер, поэтому
rate, color, gravity, … можно менять в рантайме.
Частицы рисуются материалом частиц по умолчанию (Material.particles() — точечные спрайты,
повёрнутые к камере; мягкие круглые точки, пока не дашь текстуру), если не передать свой
скомпилированный шейдер через material.
Обзор
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Текстурный флипбук-эффект (лист спрайтов с кадрами):
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]:
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)Опции
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); объект выбирает режим
направления плюс опциональную рандомизацию:
startVelocity: {
dir?: Vec3Like // запуск вдоль этого направления, или…
from?: Vec3Like // …прочь от этой точки, или…
to?: Vec3Like // …к этой точке
speed?: number | { min: number, max: number } // масштабирует направление; диапазон = на частицу
spread?: number // полуугол конуса (радианы) — дрожание в его пределах
randomizeAngle?: { min, max } // асимметричное дрожание: два угла (x/y), радианы
}sparks.startVelocity = { dir: [0, 1, 0], speed: { min: 2, max: 5 }, spread: 0.25 }Движущиеся эмиттеры — шлейфы
По умолчанию вся система едет на своём узле: подвинь эмиттер — и каждая живая частица сдвинется
вместе с ним (факел, который несут по уровню). Для шлейфов — дым из-под колёс, грязь,
кильватер, выхлоп, пыль от шагов — нужно обратное: частицы остаются в мире там, где родились, а
эмиттер едет дальше. Это space: 'world' плюс два компаньона:
rateOverDistanceэмитит на мировую единицу пройденного пути (поверхrate), с точками спавна, равномерно распределёнными вдоль пути — плотность шлейфа не зависит от скорости, без покадровых комков.inheritVelocityотдаёт каждой частице долю скорости эмиттера при спавне, чтобы дым выглядел сорванным с движущейся машины, а не возникшим из ниоткуда.
// дым дрифта у колеса: прикрепи к узлу колеса и просто езжай
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.
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 секунд, а головой лента остаётся приклеенной к
узлу.
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 за жизнь частицы.
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 держит
последнее значение.
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 — по возрастанию: они запекаются в короткую нативную кривую,
а не в произвольный сплайн; невалидная кривая отклоняется с предупреждением в консоли, прежняя
остаётся.
Живые сеттеры и методы
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).
Подводные камни
// ✗ покадровый 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