GPU Particles з використанням Compute і Geometry шейдерів

Привіт, дорогий читачу!

Сьогодні ми продовжимо вивчення графічного конвеєра, і я розповім про таких чудових речей, як Compute Shader та Geometry Shader на прикладі створення системи 1000000+ частинок, які в свою чергу є не крапками, а квадратами (billboard quads) і мають свою текстуру. Іншими словами, ми виведемо 2000000+ текстурованих трикутників при FPS > 100 (на бюджетній відеокарті GeForce 550 Ti).



Введення
Я дуже багато писав про шейдери серед своїх статей, але оперували ми завжди тільки двома типами: Vertex Shader, Pixel Shader. Однак, з появою DX10+ з'явилися нові типи шейдерів: Geometry Shader, Domain Shader, Hull Shader, Compute Shader. На всяк випадок нагадаю, як виглядає графічний конвеєр зараз:



Відразу обмовлюся, що в цій статті ми не торкнемося Domain Shader Hull Shader, про тесселяцію я напишу в наступних статтях.

Невивченим залишається тільки Geometry Shader. Що ж таке Geometry Shader?

Глава 1: Geometry Shader
Vertex Shader займається обробкою вертексов, Pixel Shader займається обробкою пікселів, і як можна здогадатися Geometry Shader займається обробкою примітивів.

Цей шейдер є необов'язковою частиною конвеєра, тобто його може і не бути: вертексы безпосередньо надходять до Primitive Assembly Stage і далі йде растеризування примітиву.
Geometry Shader знаходиться між Primitive Assembly Stage Rasterizer Stage.

На вхід він може отримати інформацію як про зібраний примітив, так і сусідніх примітивах:

image

На виході у нас є потік примітивів, куди ми в свою чергу додаємо примітив. Причому тип повертаного примітиву може відрізняється від вхідного. Наприклад, отримуємо Point, повертаємо Line. Приклад простого геометричного шейдера, який нічого не робить і просто з'єднує вхід з виходом:

struct PixelInput
{
float4 Position : SV_POSITION; // стандартний System-Value для вертекса
};

[maxvertexcount(1)] // максимальна кількість вертексов, яке ми можемо додати
void SimpleGS( point PixelInput input[1], inout PointStream<PixelInput> stream )
{
PixelInput pointOut = input[0]; // отримання вертекса

stream.Append(pointOut); // додавання вертекса
stream.RestartStrip(); // створюємо примітив (Point - один вертекс)
}

Глава 2: StructuredBuffer
В DirectX10+ з'явився такий тип буферів Structured Buffer, такий буфер може бути описаний програмістом як йому завгодно, тобто в самому класичному розумінні — це однорідний масив структур певного типу, що зберігається в пам'яті GPU.

Давайте спробуємо створити подібний буфер для нашої системи частинок. Опишемо якими властивостями володіє частка (на стороні C#):

public struct GPUParticleData
{
public Vector3 Position;
public Vector3 Velocity;
};

І створимо сам буфер (з використанням хелперу SharpDX.Toolkit):

_particlesBuffer = Buffer.Structured.New<GPUParticleData>(graphics, initialParticles, true);

Де initialParticles — масив GPUParticleData з розміром потрібну кількість частинок.

Варто відзначити, що прапори при створенні буфера встановлюються наступні:

BufferFlags.ShaderResource — для можливості звернення до буферу з шейдера
BufferFlags.StructuredBuffer — вказує на належність буфера
BufferFlags.UnorderedAccess — для можливості зміни буфера з шейдера

Створимо буфер розміром у 1 000 000 елементів і заповнимо його випадковими елементами:

GPUParticleData[] initialParticles = new GPUParticleData[PARTICLES_COUNT];
for (int i = 0; i < PARTICLES_COUNT; i++)
{
initialParticles[i].Position = random.NextVector3(new Vector3(-30f, -30f, -30f), new Vector3(30f, 30f, 30f));
}

Після чого у нас в пам'яті GPU буде зберігатися буфер з 1 000 000 елементів з випадковими значеннями.

Глава 3. Рендер Point-частинок
Тепер необхідно придумати, як же нам промалювати цей буфер? Адже у нас навіть немає вертексов! Вертексы ми буде генерувати на ходу, виходячи з значень нашого структурного буфера.

Створимо два шейдера Vertex Shader Pixel Shader.
Для початку опишемо вхідні дані для шейдерів:

struct Particle // опис структури на GPU
{
float3 Position;
float3 Velocity;
};

StructuredBuffer<Particle> Particles : register(t0); // буфер частинок
cbuffer Params : register(b0) // матриці виду і проекції
{
float4x4 View;
float4x4 Projection;
};

// т. к. вертексов у нас немає, ми можемо отримати поточний ID вертекса при малюванні без використання Vertex Buffer
struct VertexInput
{
uint VertexID : SV_VertexID;
};

struct PixelInput // описує вертекс на виході з Vertex Shader
{
float4 Position : SV_POSITION; 
};

struct PixelOutput // колір пікселя результуючого
{
float4 Color : SV_TARGET0;
};

Ну і розглянемо більш докладно шейдери, для початку, вертексный:

PixelInput DefaultVS(VertexInput input)
{
PixelInput output = (PixelInput)0;

Particle particle = Particles[input.VertexID];

float4 worldPosition = float4(particle.Position, 1);
float4 viewPosition = mul(worldPosition, View);
output.Position = mul(viewPosition, Projection);
return output;
}

У цій країні магії — ми просто читаємо з буфера частинок конкретну частку за поточним VertexID (а він у нас лежить в межах від 0 до 999999) і використовуючи позицію частинки — проектуємо в екранний простір.

Ну а Pixel Shader простіше простого:

PixelOutput DefaultPS(PixelInput input)
{
PixelOutput output = (PixelOutput)0;

output.Color = float4((float3)0.1, 1);

return output;
}

Задаємо колір частинки float4(0.1, 0.1, 0.1, 1). Чому 0.1? Тому, що часток у нас мільйон, і ми будемо використовувати Additive Blending.

Задамо буфери і отрисуем геометрію:

graphics.ResetVertexBuffers(); // на всякий випадок скинемо буфери

graphics.SetBlendState(_additiveBlendState); // включимо Additive Blend State

// і встановимо наш буфер частинок як SRV (тільки читання).
_particlesRender.Parameters["Particles"].SetResource<SharpDX.Direct3D11.ShaderResourceView>(0, _particlesBuffer); 

// матриці
_particlesRender.Parameters["View"].SetValue(camera.View);
_particlesRender.Parameters["Projection"].SetValue(camera.Projection);

// встановимо шейдер
_particlesRender.CurrentTechnique.Passes[0].Apply();

// виконаємо отрисвоку 1000000 частинок у вигляді точок
graphics.Draw(PrimitiveType.PointList, PARTICLES_COUNT);

Ну і помилуємося першою перемогою:



Глава 4: Рендер QuadBillboard-частинок
Якщо ви не забули першу главу, то можна сміливо перетворити наш набір точок в повноцінні Billboard-и, складаються з двох трикутників.

Трохи розповім про те, що таке QuadBillboard: це квадрат виконаний з двох трикутників і цей квадрат завжди повернений до камери.

Як створити цей квадрат? Нам потрібно придумати алгоритм швидкої генерації подібних квадратів. Давайте подивимося на дещо Vertex Shader. Там у нас є три простору при побудові SV_Position:

  1. World Space — позиція вертекса у світових координатах
  2. View Space — позиція вертекса у видових координатах
  3. Projection Space — позиція вертекса в екранних координатах


View Space як раз те, що нам потрібно, адже ці координати знаходяться як раз щодо камери і площину (-1 + p.x, -1 + p.y, p.z) -> (1 + p.x, 1 + p.y, p.z) створена в цьому просторі завжди буде мати нормаль, яка спрямована на камеру.

Тому, дещо змінимо в шейдере:

PixelInput TriangleVS(VertexInput input)
{
PixelInput output = (PixelInput)0;

Particle particle = Particles[input.VertexID];

float4 worldPosition = float4(particle.Position, 1);
float4 viewPosition = mul(worldPosition, View);
output.Position = viewPosition;
output.UV = 0;

return output;
}

На вихід SV_Position ми будемо передавати не ProjectionSpace-position, ViewSpace-position, для того, щоб створити нові примітиви в Geometry Shader ViewSpace.

Додамо нову стадію:

// функція зміни вертекса і подальша проекція його в Projection Space
PixelInput _offsetNprojected(PixelInput data, float2 offset, float2 uv)
{
data.Position.xy += offset;
data.Position = mul(data.Position, Projection);
data.UV = uv;

return data;
}

[maxvertexcount(4)] // результат роботи GS - 4 вертекса, які утворюють TriangleStrip
void TriangleGS( point PixelInput input[1], inout TriangleStream<PixelInput> stream )
{
PixelInput pointOut = input[0];

const float size = 0.1 f; // розмір квадрата доконаного 
// опис квадрата
stream.Append( _offsetNprojected(pointOut, float2(-1,-1) * size, float2(0, 0)) );
stream.Append( _offsetNprojected(pointOut, float2(-1, 1) * size, float2(0, 1)) );
stream.Append( _offsetNprojected(pointOut, float2( 1,-1) * size, float2(1, 0)) );
stream.Append( _offsetNprojected(pointOut, float2( 1, 1) * size, float2(1, 1)) );

// створити TriangleStrip
stream.RestartStrip();
}

Ну і так, як у нас є тепер UV — ми можемо прочитати текстуру піксельному шейдере:
PixelOutput TrianglePS(PixelInput input)
{
PixelOutput output = (PixelOutput)0;
float particle = ParticleTexture.Sample(ParticleSampler, input.UV).x * 0.3; 
output.Color = float4((float3)particle, 1);

return output;
}

Додатково встановимо семплер і текстуру частинки для фонового:

_particlesRender.Parameters["ParticleSampler"].SetResource<SamplerState>(_particleSampler);
_particlesRender.Parameters["ParticleTexture"].SetResource<Texture2D>(_particleTexture);

Перевіряємо, тестуємо:



Глава 5: Рух частинок
Тепер все готово, у нас є особливий буфер в пам'яті GPU є рендер частинок, побудований за допомогою Geometry Shader, але подібна система — статична. Можна, звичайно, змінювати позицію на CPU, просто кожен раз читати GPU дані буфера, змінювати їх, а потім завантажувати назад, але про який GPU Power може йти мова? Така система не витримає і 100 000 частинок.

І для роботи на GPU з такими буферами можна використовувати особливий шейдер — Compute Shader. Він знаходиться поза традиційного render-pipeline і може використовуватися окремо.

Що ж таке Compute Shader?

Своїми словами, обчислювальний шейдер (Compute Shader) — це особлива стадія конвеєра, яка замінює всі традиційні (проте все ще можуть використовуватись разом з ним), дає змогу виконувати довільний код за допомогу GPU, читати/записувати дані в буфери (в тому числі і текстурні). Причому виконання цього коду відбувається паралельно, як налаштує розробник.

Давайте розглянемо виконання самого простого коду:

[numthreads(1, 1, 1)]
void DefaultCS( uint3 DTiD: SV_DispatchThreadID )
{
// DTiD.xyz - поточний потік
// ... довільний код
}

technique ComputeShader
{
pass DefaultPass
{
Profile = 10.0;
ComputeShader = DefaultCS;
}
}

На самому початку коду є поле numthreads, яке вказує кількість потоків у групі. Поки ми не будемо використовувати групові потоки і зробимо так, щоб на одну групу припадав один потік.
uint3 DTiD.xyz вказує на поточний потік.

Наступний етап — це запуск такого шейдера, він проводиться наступним чином:

_effect.CurrentTechnique.Passes[0].Apply();
graphics.Dispatch(1, 1, 1);

У методі Dispatch ми вказуємо — скільки груп потоків у нас повинно бути, причому максимальна кількість кожної розмірності обмежено 65536. І якщо ми виконаємо такий код, код шейдера на GPU виконається один раз, тому що у нас 1 група потоків, у кожній групі 1 потік. Якщо поставити, наприклад, Dispatch(5, 1, 1) — код шейдера на GPU виконається п'ять разів, 5 груп потоків, у кожній групі 1 потік. Якщо при цьому змінити ще й numthreads -> (5, 1, 1), код виконається 25 разів, причому в 5 груп потоків, у кожній групі 5 потоків. Більш детально можна розглянути, якщо поглянути на картинку:



Тепер повернемося до системи частинок, що у нас є? У нас є одновимірний масив з 1 000 000 елементів і завдання — опрацювати позиції частинок. Т. к. частинки рухаються незалежно один від одного, то цю задачу можна дуже добре розпаралелити.

В DX10 (саме цю версію CS ми використовуємо, для підтримки DX10 карт) максимальна кількість потоків на групу потоків — 768, причому у всіх трьох вимірах. Я створюю 32 * 24 * 1 = 768 потоків в сумі на кожну групу потоків, тобто наша одна група здатна обробити 768 частинок (1 потік — 1 часточка). Далі, необхідно порахувати, скільки потрібно груп потоків (з урахуванням того, що одна група опрацює 768 часток) для того, щоб обробити N-е кол-во частинок.
Порахувати це можна за формулою:

int numGroups = (PARTICLES_COUNT % 768 != 0) ? ((PARTICLES_COUNT / 768) + 1) : (PARTICLES_COUNT / 768);
double secondRoot= System.Math.Pow((double)numGroups, (double)(1.0 / 2.0));
secondRoot= System.Math.Ceiling(secondRoot);
_groupSizeX = _groupSizeY = (int)secondRoot;

Після чого — ми можемо викликати Dispatch(_groupSizeX, _groupSizeY, 1) і шейдер буде здатний паралельно обробити N-е кол-во елементів.

Для доступу до конкретного елементу використовують формулу:
uint index = groupID.x * THREAD_IN_GROUP_TOTAL + groupID.y * GROUP_COUNT_Y * THREAD_IN_GROUP_TOTAL + groupIndex; 

Далі наведу оновлений код шейдера:

struct Particle
{
float3 Position;
float3 Velocity;
};

cbuffer Handler : register(c0)
{
int GroupDim;
uint MaxParticles;
float DeltaTime;
};

RWStructuredBuffer<Particle> Particles : register(u0);

#define THREAD_GROUP_X 32
#define THREAD_GROUP_Y 24
#define THREAD_GROUP_TOTAL 768

[numthreads(THREAD_GROUP_X, THREAD_GROUP_Y, 1)]
void DefaultCS( uint3 groupID : SV_GroupID, uint groupIndex : SV_GroupIndex )
{
uint index = groupID.x * THREAD_GROUP_TOTAL + groupID.y * GroupDim * THREAD_GROUP_TOTAL + groupIndex; 

[flatten]
if(index >= MaxParticles)
return;

Particle particle = Particles[index];

float3 position = particle.Position;
float3 velocity = particle.Velocity;

// payload

particle.Position = position + velocity * DeltaTime;
particle.Velocity = velocity;

Particles[index] = particle;
}

technique ParticleSolver
{
pass DefaultPass
{
Profile = 10.0;
ComputeShader = DefaultCS;
}
}

Тут відбувається ще одна магія, ми використовуємо наш буфер частинок як особливий ресурс: RWStructuredBuffer, це означає, що ми можемо читати і писати в цей буфер.
(!) Необхідна умова для запису — цей буфер повинен бути при створенні позначений прапором UnorderedAccess.

Ну і фінальний етап, ми встановлюємо ресурс для шейдера UnorderedAccessView наш буфер і викликаємо Dispatch:

/* SOLVE PARTICLES */
_particlesSolver.Parameters["GroupDim"].SetValue(_threadGroupSize);
_particlesSolver.Parameters["MaxParticles"].SetValue(PARTICLES_COUNT);
_particlesSolver.Parameters["DeltaTime"].SetValue(deltaTime);

_particlesSolver.Parameters["Particles"].SetResource<SharpDX.Direct3D11.UnorderedAccessView>(0, _particlesBuffer);

_particlesSolver.CurrentTechnique.Passes[0].Apply();

graphics.Dispatch(
_threadSize,
_threadSize,
1);

_particlesSolver.CurrentTechnique.Passes[0].UnApply(false);

Після завершення виконання коду обов'язково необхідно прибрати UnorderedAccessView з шейдера, інакше ми не зможемо його використовувати!

Давайте щось зробимо з частками, напишемо найпростіший солвер:

float3 _calculate(float3 anchor, float3 position)
{
float3 direction = anchor - position;
float distance = length(direction);
direction /= distance;

return direction * max(0.01, (1 / (distance*distance)));
}

// main
{
...
velocity += _calculate(Attractor, position);
velocity += _calculate(-Attractor, position);
...
}

Attractor задамо в константному буфері.

Компілюємо, запускає і милуємося:


Висновок 1
Якщо говорити про частинках, то нічого не заважає створити повноцінну і потужну систему частинок: точки досить легко відсортувати (для забезпечення прозорості), застосувати техніку soft particles при малюванні, а так само враховувати освітлення «не світяться» частинок. Обчислювальні шейдери в основному застосовуються для створення ефекту Bokeh Blur (тут потрібні ще геометричні), для створення Tiled Deferred Renderer, etc. Геометричні шейдери, наприклад, можна використовувати тоді, коли необхідно згенерувати багато геометрії. Найяскравіший приклад — трава і частинки. До речі, застосування GS CS безмежні і обмежуються лише фантазією розробника.

Висновок 2
Традиційно прикріплюю до посту повний вихідний код і демо.

Висновок 3
Мені дуже приємно, коли люди проявляють інтерес до того, що я пишу. І для мене дуже важлива реакція на статтю, будь вона у вигляді плюса або мінуса з конструктивним коментарем. Так я зможу визначити — які теми більше цікаві хабрасообществу, а які ні.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.