Данный пост будет о том, как разработать свою собственную, и достаточно производительную (на моем компьютере спокойно отрисовывается и анимируется 1 000 000 частиц в реальном времени), систему частиц. Писать будем на языке C++, в качестве платформы будет использован DirectX 9.
Вторая часть доступна здесь.
Пример одного из кадров визуализации (кликабельно):
Для начала стоит сказать почему именно C++ и DirectX9, а не, скажем, XNA, или вообще GDI. Перед тем как определиться, я рассмотрелпопробовал много вариантов: HTML+JS (когда разрабатывал концепцию), С# и GDI, C++ и GDI, С# и XNA. Все из перечисленных вариантов не позволили достичь необходимой производительности (реал-тайм рендеринг более 50000 частиц), поэтому я стал рассматривать более серьезные варианты. Первое что пришло в голову было DirectDraw, но его давно никто не разрабатывает, поэтому выбор пал на Direct3D. Можно было использовать и OpenGL, но D3D мне как-то ближе.
0. Концепция и требования
Система будет рисовать и анимировать частицы. Анимацию будем производить по некой формуле (в качестве примера я использовал Закон всемирного тяготения). С системой можно взаимодействовать из вне, передавая какие-то данные в реальном времени.
Требования.
Нам хочется, чтобы наша система частиц была достаточно производительной, чтобы её можно было использовать для отрисовки в реальном времени, должна быть гибкой в настройке, позволяла применять спрайты, различные эффекты и пост-эффекты.
Пойдем по-порядку:
1. Производительность. Пожалуй, чего-то быстрее чем CC++ будет сложно найти, да и Direct3D широко применяется при разработке компьютерных игр. Нам его возможностей точно хватит.
2. Отрисовка в реальном времени. Собственно Direct3D (OpenGL) для этого и используют. Выбранный кандидат подходит.
3. Гибкость в настройке. В DirectX есть такая замечательная вещь, как шейдеры. Можно реализовать что угодно, не переписывая больше ничего, кроме них.
4. Спрайты. В DirectX ими достаточно легко пользоваться. Подходит.
5. Эффекты, пост-эффекты. Для реализации этого нам помогут шейдеры.
Теперь о тонкостях, которые необходимо учесть для того, чтобы достичь достаточной производительности. Т.к. количество частиц очень велико, то на отрисовку их подавать лучше одним куском, дабы не терять в производительности из-за огромного количества вызовов функций отрисовки. Ну и о памяти тоже нужно позаботиться, поэтому оптимальным вариантом будет хранение данных в виде массива точек.
Все теоретические вопросы мы решили, теперь перейдем к реализации.
1. Инициализация Direct3D и создание камеры
Для работы нам понадобится собственно среда разработкикомпилятор и DirectX SDK
Первое, что нужно сделать это создать объект Direct3D9, после устройство для вывода и окно, где и будет все отображаться.
// Создание и регистрация класса окна
WNDCLASSEX wc = {sizeof(WNDCLASSEX), CS_VREDRAW|CS_HREDRAW|CS_OWNDC,
WndProc, 0, 0, hInstance, NULL, NULL, (HBRUSH)(COLOR_WINDOW+1),
NULL, L"RenderToTextureClass", NULL};
RegisterClassEx(&wc);
// Создание окна
HWND hMainWnd = CreateWindowW(L"RenderToTextureClass",
L"Render to texture",
WS_POPUP, 0, 0, Width, Height,
NULL, NULL, hInstance, NULL);
// Создание объекта Direct3D
LPDIRECT3D9 d3d = Direct3DCreate9(D3D_SDK_VERSION);
// Создание и устанока параметров устройства
D3DPRESENT_PARAMETERS PresentParams;
memset(&PresentParams, 0, sizeof(D3DPRESENT_PARAMETERS));
PresentParams.Windowed = TRUE; // Наше приложение не полноэкранное
// Указываем как будет осуществляться переключение буферов в цепочке переключений.
// Для большинства случаев можно указать значение D3DSWAPEFFECT_DISCARD.
PresentParams.SwapEffect = D3DSWAPEFFECT_DISCARD;
LPDIRECT3DDEVICE9 device = NULL;
// Создаем устройство
d3d->CreateDevice(D3DADAPTER_DEFAULT, // Используем адаптер по умолчанию
D3DDEVTYPE_HAL, // Используем аппаратное ускорение
hMainWnd, // Рисовать будем в этом окне
D3DCREATE_HARDWARE_VERTEXPROCESSING, // Будем использовать аппаратную обработку вершин
&PresentParams, // Параметры, которые мы заполнили выше
&device); // Указатель на переменную, в которую будет добавлен объект,
// представляюищий устройство.
device->SetRenderState(D3DRS_LIGHTING,FALSE); // Мы не будем использовать освещение
device->SetRenderState(D3DRS_ZENABLE, FALSE); // И буфер глубины тоже
В коде выше мы создаем обычное окно, в котором будет происходить отрисовка. Далее объект Direct3D. И наконец объект устройства, который мы и будем использовать для рисования.
Пару слов о аппаратном ускорении. Многие вычисления можно производить с помощью процессора, эмулируя видеокарту, но т.к. обычный процессор не очень подходит для этих целей (в нем, в лучшем случае, 4 ядра, а в видеокарте используется десятки, а то и сотни), то это скажется на быстродействии. В некоторых случаях очень сильно. Поэтому по возможности лучше использовать аппаратное ускорение.
Еще следует не забывать об установки матриц проекции и камеры. Если коротко, то матрица проекции используется для преобразование 3D данных в 2D, а камера описывает то, что мы видим и куда смотрим.
Но здесь я внесу одно упрощение, ортогональную матрицу проекции, т.к. фактически наши частицы плоские точки, и им не нужны особые расчеты перспективы, мы можем обойтись и без камеры. В ортогональной проекции Z фактически не учитывается, и объекты не изменяют размер в зависимости от положения в пространстве.
// Инициализация матриц
D3DXMATRIX matrixView;
D3DXMATRIX matrixProjection;
// Матрица вида
D3DXMatrixLookAtLH(
&matrixView,
&D3DXVECTOR3(0,0,0),
&D3DXVECTOR3(0,0,1),
&D3DXVECTOR3(0,1,0));
// Матрица проекции
D3DXMatrixOrthoOffCenterLH(&matrixProjection, 0, Width, Height, 0, 0, 255);
// Установка матриц в качестве текущих
device->SetTransform(D3DTS_VIEW,&matrixView);
device->SetTransform(D3DTS_PROJECTION,&matrixProjection);
2. Создания частиц и буфера для них
Все приготовления мы выполнили, осталось лишь создать частицы и приступить к рисованию.
struct VertexData
{
float x,y,z;
};
struct Particle
{
float x, y, vx, vy;
};
std::deque<Particle> particles;
VertexData используется для хранения данных о частице в GPU (вершинный буфер), и содержит координаты нашей частицы в пространстве. Эта структура имеет особый формат, и фактически графический процессор будет брать из нее сведения что и где рисовать.
Particle будет представлять нашу частицу, и содержит координаты и скорость.
В particles же будут хранится сведения о всех наших частицах. Этой информацией мы будем пользоваться для расчетов движения частиц.
//Заполняем внутренний массив сведениями о частицах
srand(clock());
Particle tmp;
for( int i = 0; i<particleCount; ++i )
{
tmp.x = rand()%Width;
tmp.y = rand()%Height;
particles.push_back( tmp );
}
LPDIRECT3DVERTEXBUFFER9 pVertexObject = NULL;
LPDIRECT3DVERTEXDECLARATION9 vertexDecl = NULL;
size_t count = particles.size();
VertexData *vertexData = new VertexData[count];
for(size_t i=0; i<count; ++i)
{
vertexData[i].x = particles[i].x;
vertexData[i].y = particles[i].y;
vertexData[i].z = 0.f;
vertexData[i].u = 0;
vertexData[i].v = 0;
}
void *pVertexBuffer = NULL;
// Создаем вершинный буфер
device->CreateVertexBuffer(
count*sizeof(VertexData), // Необходимое количество байт
D3DUSAGE_WRITEONLY, // Говорим GPU, что мы не будем читать данные из буфера
D3DFVF_XYZ, // Буфер будет хранить координаты XYZ
D3DPOOL_DEFAULT, // Размещение в пуле по умолчанию
&pVertexObject, // Указатель на объект, куда будем помещен буфер
NULL); // Зарезервированный параметр. Всегда NULL
// Блокируем буфер, чтобы записать туда данные о вершинах
pVertexObject->Lock(0, count*sizeof(VertexData), &pVertexBuffer, 0);
// Копируем данные в буфер
memcpy(pVertexBuffer, vertexData, count*sizeof(VertexData));
pVertexObject->Unlock();
delete[] vertexData;
vertexData = nullptr;
// Создаем описание данных в буфере
// Наш буфер хранит 3 float, начиная с 0-го байта, представляющие позицию элемента
D3DVERTEXELEMENT9 decl[] =
{
{ 0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 },
D3DDECL_END()
};
// Создаем объект с описанием вершин
device->CreateVertexDeclaration(decl, &vertexDecl);
Прокомментирую некоторые моменты.
При создание буфера, мы передаем параметр D3DUSAGE_WRITEONLY, говоря GPU, что мы не будем читать данные из буфера. Это позволит графическому процессору произвести необходимые оптимизации, и увеличит скорость рендеринга.
VertexDeclaration обычно используют для работы с шейдерами. Если шейдеры не требуются, то можно обойтись без создания этого объекта.
3. Отрисовка частиц
Теперь частицы необходимо нарисовать. Делается это очень просто:
// Очищаем экранный буфер
device->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,0), 1.0f, 0 );
// Устанавливаем источник вершин
device->SetStreamSource(0, pVertexObject, 0, sizeof(VertexData));
// Указываем, что хранится в буфере
device->SetVertexDeclaration(vertexDecl);
device->BeginScene();
// Рисуем
device->DrawPrimitive(D3DPRIMITIVETYPE::D3DPT_POINTLIST, // Данные в буфере - точки
0, // начинаем с 0-го байта
particles.size()); // Количество объектов столько, сколько у нас частиц
device->EndScene();
Одно важно замечание: BeginScene() необходимо вызывать каждый раз перед началом рисования, а EndScene() после окончания.
Анимация
Ну и конечно никак без анимации, иначе какая же это система частиц. В качестве примера я использовал Закон всемирного тяготения.
// Получаем координаты курсора
POINT pos;
GetCursorPos(&pos);
RECT rc;
GetClientRect(hMainWnd, &rc);
ScreenToClient(hMainWnd, &pos);
const int mx = pos.x;
const int my = pos.y;
const auto size = particles.size();
float force;
float dist;
VertexData *pVertexBuffer;
// Блокируем весь буфер, для изменения
pVertexObject->Lock(0, 0, (void**)&pVertexBuffer, D3DLOCK_DISCARD);
for(int i = 0; i < size; ++i )
{
auto &x = particles[i].x;
auto &y = particles[i].y;
dist = sqrt( pow( x - mx, 2 ) + pow( y - my, 2 ) );
if( dist < 20 )
{
force = 0;
}
else
{
force = G / pow( dist, 2 );
}
const float xForce = (mx - x) * force;
const float yForce = (my - y) * force;
particles[i].vx *= Resistance;
particles[i].vy *= Resistance;
particles[i].vx += xForce;
particles[i].vy += yForce;
x+= particles[i].vx;
y+= particles[i].vy;
if( x > Width )
x -= Width;
else if( x < 0 )
x += Width;
if( y > Height )
y -= Height;
else if( y < 0 )
y += Height;
pVertexBuffer[i].x = particles[i].x;
pVertexBuffer[i].y = particles[i].y;
}
pVertexObject->Unlock();
При блокировке буфера, я указал флаг D3DLOCK_DISCARD, он позволяет продолжить графическому процессору отрисовку частиц из старого буфера. Нам при этом возвращают новый. Эта маленькая хитрость уменьшает простои оборудования.
На этом первая часть статьи подошла к концу. В следующей части будет описано текстурирование частиц, вершинные и пиксельные шейдеры, а так же эффекты и пост-эффекты. Так же в конце 2-ой части вы увидите ссылки на демонстрацию и её полный исходный код.
Автор: A1ex