Плюсы данной системы:

  • Подходит для android и ios. До сих пор не все мобильные устройства поддерживают compute шейдера (или поддерживают, но с ужасной производительностью), которые необходимы для работы VFX Graph, из-за чего в качестве альтернативы остаётся только Particle System.

  • Большая производительность. По моим тестам, она на ~11% быстрее чем Particle System (360fps vs 410fps при пустой сцене) в идеальных для неё условиях. Разница может быть ещё больше, если у вас загружен процессор.

  • Максимально гибкая кастомизация. Легко добавить несколько функций, вроде изменения цвета в зависимости от скорости или коллизию с определённой плоскостью.

Минусы:

  • Работает только с 2д изображениями, не спавнит меши.

  • Плохо подходит для обработки коллизий с внешним миром. Если вы хотите чтобы частицы отскакивали от всех стенок вокруг, придётся городить костыли и отдельным скриптом передавать информацию о внешних объектах.

Кратко о Particle System и VFX Graph

Зачем нужны compute shader'a для VFX Graph? Дело в том, что он при помощи них создаёт в буферах видеокарты необходимую информацию о треугольниках и вершинах для отрисовки, создавая их по мере надобности. Поскольку это шейдера, то передача информации внутри видеокарты намного быстрее, чем передача информации от процессора до видеокарты.

Что делает Particle System на процессоре? Практически всё, что связанно с положением в пространстве, а также занимается созданием новых частиц. Благодаря этому, Particle System может обрабатывать коллизии с коллайдерами, поскольку именно процессор занимается физикой. Эта система хороша, когда нам нужны несколько десятков частиц с физикой. Но если нам не нужна физика, или мы можем ограничиться одним или несколькими объектами для её симуляции, то процессор в плане выполнения сотен однообразных задач быстро обойдёт видеокарта.

Но как же мы будем создавать частицы, если ни процессор, ни compute shader'а не подходят? Дело в том, что мы их не будем создавать. Мы используем один меш состоящий из прямоугольников, при этом каждый из прямоугольников будет обладать заранее заданным уникальным цветом, который можно будет использовать как идентификатор. Поскольку меш изначально существует, мы не тратим никаких усилий процессора для создания частиц. Вы можете скачать пример таких мешей вместе с готовой системой на моём гитхабе.

Основа системы

В первую очередь частицы должны поворачиваться своей плоскостью к камере. Для этого мы умножим позицию наших частиц на матрицу преобразования InverseView. В шейдерах обычно используется несколько матриц преобразований: от пространства модели к пространству мира UNITY_MATRIX_M , из пространства мира к пространству камеры (где камера находится в начале координат) UNITY_MATRIX_V , и из пространства камеры в пространство экрана UNITY_MATRIX_P . При преобразовании в пространство мира к модели применяется вращение объекта в сцене. Этого мы и собираемся избежать, написав свой код для преобразований.

float3 BillbordFaceCamera(float3 PositionOS, float Scale)
{
    float3 _Object_Scale = float3(length(float3(UNITY_MATRIX_M[0].x, UNITY_MATRIX_M[1].x, UNITY_MATRIX_M[2].x)),
                                 length(float3(UNITY_MATRIX_M[0].y, UNITY_MATRIX_M[1].y, UNITY_MATRIX_M[2].y)),
                                 length(float3(UNITY_MATRIX_M[0].z, UNITY_MATRIX_M[1].z, UNITY_MATRIX_M[2].z)));


    float3 tempPos = PositionOS * _Object_Scale * Scale;
    float3 worldPos = GetAbsolutePositionWS(UNITY_MATRIX_M._m03_m13_m23); //позиция объекта с шейдером (общая для всех вершин)

    float4 tempPosForTransform = float4(tempPos, 0);
    float3 OutMatrix = mul(UNITY_MATRIX_I_V, tempPosForTransform).xyz + worldPos;

    float3 res = TransformWorldToObject(OutMatrix);
    return res;
}

Матрица UNITY_MATRIX_I_V позволяет нам делать преобразования из пространства камеры в пространство мира. Благодаря этому трюку, поместив эту строчку в вертексную часть шейдера, мы получаем билборд.

OUT.positionCS = TransformObjectToHClip(BillbordFaceCamera(IN.positionOS.xyz, Scale));
Невероятно, но мы только начинаем
Невероятно, но мы только начинаем

Далее, нам понадобятся рандомные значения для симуляции хаотичного поведения частиц. Используем функцию Hash33 .

float3 Hash33(float3 InVector3)
{
    uint3 v = (uint3) (int3) round(p);
    v.x ^= 1103515245U;
    v.y ^= v.x + v.z;
    v.y = v.y * 134775813;
    v.z += v.x ^ v.y;
    v.y += v.x ^ v.z;
    v.x += v.y * v.z;
    v.x = v.x * 0x27d4eb2du;
    v.z ^= v.x << 3;
    v.y += v.z << 3; 
    float3 Out = v * (1.0 / float(0xffffffff));
    return Out;
}

Эта функция генерации хеша работает с большими входными данными, поскольку округляет входные значения до int. Она детерминистическая, и может обрабатывать очень большие входные значения без повторов. Случайность достигается путём операций над входными битами. Поскольку здесь не используется никаких тригонометрических функций (которые часто используются для генерации хешей), этот код очень быстро работает.

Теперь давайте привнесём немного жизни. Каждая жизнь должна начинаться, и каждая жизнь должна кончаться.

half3 GetLifeTime(half _DebugTime, half _ManualTime, half _ParticleLifeSpeed, half offset)
{
    
    half lifeTime;
    
    if (_DebugTime)
    {
        lifeTime = _ManualTime;
    }
    else
    {
        lifeTime = _Time.y * _ParticleLifeSpeed;
    }

    lifeTime += offset;

    half lifeTimeFrac = frac(lifeTime);
    half lifeTimeCeil = ceil(lifeTime);

    half3 result = half3(lifeTime, lifeTimeFrac, lifeTimeCeil);

    return result;
}

Чтобы получить время жизни каждой частицы, я умножаю время на _ParticleLifeSpeed и беру остаток с помощью функции frac . Таким образом, мы получаем число, равное 0 в начале жизни, и равное 1 в конце. Для удобства дебага есть возможность самостоятельно указывать значение lifeTime. Если вы хотите вводить время жизни частицы напрямую в секундах, то делайте над ними операцию rcp() чтобы получить _ParticleLifeSpeed . Операция ceil нужна, чтобы получить число, которое увеличивается каждый раз после смерти частицы. Теперь мы можем применит хеш следующим образом в вертексной части:

float3 HashedHash3 = Hash33((IN.color.rrr ) * 255);
OUT.color = HashedHash3.xyzz;
half3 lifeTime = GetLifeTime(_DebugTime, _ManualTime, _ParticleSpeed, IN.color.r + HashedHash3.x);
HashedHash3 = Hash33((IN.color.rrr + lifeTime.z) * 255);
float3 ExtendedHash3 = HashedHash3 * 2 - 1;

Теперь у каждой частицы есть свой уникальный хеш, который к тому же и не повторяется со временем.

Теперь мы имеем всю базовую информацию, чтобы начать двигать наши частицы. Я использую следующий код:

Hidden text

В этом нет ничего фундаментального, поэтому постараюсь кратко

 float SpreadRemapped = Remap_float(_ParticleSpead, float2(0, 360), float2(0, 2));
float3 DirectionToMove = normalize(SpreadRemapped * ExtendedHash3 + _ParticleDirectional);
float3 VelocityNow = lerp(_ParticleVelocityStart, _ParticleVelocityEnd, lifeTime.y);


float3 GravityAndWind = TransformWorldToObject(_Wind + _Gravityy + GetAbsolutePositionWS(UNITY_MATRIX_M._m03_m13_m23)) * lifeTime.y;

float3 MoveAndVelocity = (DirectionToMove * VelocityNow + GravityAndWind) * lifeTime.y;
float3 SpawnPoint = _EmitterDimensions * ExtendedHash3;

float3 PositionToAdd = MoveAndVelocity + SpawnPoint;

float RotationRandDir = sign(ExtendedHash3.r);
float RotationRandOffset = ExtendedHash3.g * 180;
float RotationAmount = _RotationSpeed * _Time.y + _Rotation;
if (_RotationRandomOffset)
{
    RotationAmount += RotationRandOffset;
}
if (_RandomizeRotationDirection)
{
    RotationAmount *= RotationRandDir;
}

float3 RotatedPos = RotateAboutAxis_Degrees_float(IN.positionOS.xyz, float3(0,0,1), RotationAmount);

И добавляю результат после преобразования.

 OUT.positionCS = TransformObjectToHClip(BillbordFaceCamera(RotatedPos, Scale) + PositionToAdd);

Чтобы вращать частицы использую эту функцию:

float3 RotateAboutAxis_Degrees_float(float3 In, float3 Axis, float Rotation)
{
    Rotation = radians(Rotation);
    float s = sin(Rotation);
    float c = cos(Rotation);
    float one_minus_c = 1.0 - c;

    Axis = normalize(Axis);
    float3x3 rot_mat =
    {   one_minus_c * Axis.x * Axis.x + c, one_minus_c * Axis.x * Axis.y - Axis.z * s, one_minus_c * Axis.z * Axis.x + Axis.y * s,
        one_minus_c * Axis.x * Axis.y + Axis.z * s, one_minus_c * Axis.y * Axis.y + c, one_minus_c * Axis.y * Axis.z - Axis.x * s,
        one_minus_c * Axis.z * Axis.x - Axis.y * s, one_minus_c * Axis.y * Axis.z + Axis.x * s, one_minus_c * Axis.z * Axis.z + c
    };
    float3 Out = mul(rot_mat,  In);
    return Out;
}

Если вывести хеш как цвет, то можно увидеть следующий результат
Если вывести хеш как цвет, то можно увидеть следующий результат

Но для полноценной системы частиц нам не хватает поддержки FlipBook'ов для анимации и прозрачности для корректного отображения.

Получаем координаты FlipBook и значения прозрачности
void Unity_Flipbook_float(float2 UV, float Width, float Height, float Tile, float2 Invert, out float2 Out)
{
    Tile = floor(fmod(Tile + float(0.00001), Width*Height));
    float2 tileCount = float2(1.0, 1.0) / float2(Width, Height);
    float base = floor((Tile + float(0.5)) * tileCount.x);
    float tileX = (Tile - Width * base);
    float tileY = (Invert.y * Height - (base + Invert.y * 1));
    Out = (UV + float2(tileX, tileY)) * tileCount;
}

float2 GetflipBookUV(half FlipbookSpeed, half MatchParticlePhase, half2 FlipBookDimenions, half RandHalf, half ParticleLife, float2 UV)
{
    half FlipBookRandPos = frac(FlipbookSpeed * _Time.y) + RandHalf;
    half FlipbookSize = FlipBookDimenions.x * FlipBookDimenions.y - 1;
    half FlipBookPos;

    if (MatchParticlePhase)
    {
        FlipBookPos = ParticleLife * FlipbookSize;
    }
    else
    {
        FlipBookPos = FlipBookRandPos * FlipbookSize;
    }
    float2 resUV;
    
    float2 _Flipbook_Invert = float2(_FlipX, _FlipY);
    Unity_Flipbook_float(UV, FlipBookDimenions.x, FlipBookDimenions.y, FlipBookPos, _Flipbook_Invert, resUV);


    return resUV;    
}
float2 flipbookUV = GetflipBookUV(_FlipBookSpeed, _MatchParticlePhase, _FlipBookDimenions, HashedHash3.g, lifeTime.y, IN.uv);

Добавляем код для fade in and out, чтобы частицы плавно появлялись и исчезали.

float fadeIn = pow(lifeTime.y, _FadeInPower);
float fadeOut = pow(1 - lifeTime.y, _FadeInPower);
float fadeInAndOut = saturate(fadeIn * fadeOut * _Opacity);

Теперь нам нужно передать всю необходимую для отображения информацию во фрагментарную часть шейдера. Каждая из переменных переданная из вершин будет линейно интерполироваться между ними. Поэтому для пущей оптимизации запакуем нужные данные в float4 , чтобы интерполятор видеокарты работал с ними как с одной переменной.

OUT.positionCS = TransformObjectToHClip(BillbordFaceCamera(RotatedPos, Scale) + PositionToAdd);
OUT.uv = IN.uv;
OUT.particleData = float4(flipbookUV.x, flipbookUV.y, lifeTime.y, fadeInAndOut);
Итого только три переменных для интерполяции (не считая семплирования текстуры)
struct Varyings 
{
	float4 positionCS 	: SV_POSITION;
	float2 uv		    : TEXCOORD0;
    float4 particleData : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

Код во фрагментной части, применение текстуры и прозрачности
half4 UnlitPassFragment(Varyings IN) : SV_Target 
{
	half4 baseMap = SAMPLE_TEXTURE2D(_FlipBook, sampler_FlipBook, IN.particleData.xy);

    half4 ColorToAdd = lerp(_StartColor, _EndColor, IN.particleData.z);
    float alph;
    if (_UseTextureRGBAlpha)
    {
        float baseMapAlph = smoothstep(0, _TextureAlphaSmoothstep, (baseMap.r + baseMap.g + baseMap.b) / 3);
        alph = IN.particleData.a * baseMapAlph * ColorToAdd.a;
    }
    else if (_UseTextureAlpha)
    {
         alph = IN.particleData.a * baseMap.a * ColorToAdd.a;
    }
    else
    {
        alph = IN.particleData.a * ColorToAdd.a;
    }

    clip(baseMap - 0.04); 
	return half4(baseMap.rgb * ColorToAdd, alph);
}

Конечный результат
Конечный результат
Дым (хоть и без огня)
Дым (хоть и без огня)
Пылевое облако
Пылевое облако

Вывод

Хоть эта система не так универсальна, как Particle System, она обладает существенным преимуществом в виде высокой производительности, и её достаточно для доброй половины эффектов. Полный код шейдера, а также меши вы найдёте на моём гитхабе.


Credentials

Статья основана на серии видео Ben'a Cloward'a, система переписана на hlsl и добавлен ряд фич.

Комментарии (5)


  1. svkozlov
    23.05.2024 12:56
    +1

    Спасибо за статью, можете выложить бенчмарки двух систем для сравнения?


    1. 5a5ha Автор
      23.05.2024 12:56
      +1

      Специально для тебя выкладываю на гитхаб билд с пустой сценой и двумя системами частиц, которые выполняют одинаковые действия (двигаются, меняют цвет и размер, используют одну текстуру и т.д.). Менять активную систему можно по пробелу.


      1. svkozlov
        23.05.2024 12:56

        спасибо


  1. nanoprikol
    23.05.2024 12:56

    Очень круто, но не повышает ли это нагрузку на ГПУ в таком случае?


    1. 5a5ha Автор
      23.05.2024 12:56

      Естественно, любой шейдер повышает нагрузку на гпу, по скольку на нём и исполняется. Здесь скорее дело в эффективности. На экране 1000 частиц, у каждой по 4 вершины, для каждой вершины нужно сделать расчёт положения. У процессоров нет столько ядер, чтобы параллельно их обработать. А видеокарта может.

      Конечно может быть такая ситуация, что у вас распределён бюджет на кадр. Цель 60 фпс, значит один кадр видеокарта и процессор не должны обрабатывать дольше 16 милисекунд(по отдельности, грубо говоря). И так вышло, что видекарта уже занята все 16 милисекунд, тогда конечно нет выбора, исполняем на цп. Просто на цп это займёт больше времени, вот и всё.