Разработка системы частиц на платформе DirectX 9. Часть I

в 17:59, , рубрики: c++, direct3d, DirectX 9, game development, particles system, Анимация и 3D графика, Программирование, система частиц, метки: , , , , ,

Данный пост будет о том, как разработать свою собственную, и достаточно производительную (на моем компьютере спокойно отрисовывается и анимируется 1 000 000 частиц в реальном времени), систему частиц. Писать будем на языке C++, в качестве платформы будет использован DirectX 9.

Вторая часть доступна здесь.

Пример одного из кадров визуализации (кликабельно):
Разработка системы частиц на платформе DirectX 9. Часть I

Для начала стоит сказать почему именно 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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js