Particle System Development on DirectX 9 Part I

  • Tutorial
This post will be about how to develop your own, and quite productive (on my computer, 1,000,000 particles in real time are quietly drawn and animated), a particle system. We will write in C ++, DirectX 9 will be used as a platform.
The second part is available here .

An example of one of the visualization frames (clickable):




To begin with, it's worth saying why it is C ++ and DirectX9, and not, say, XNA, or GDI in general. Before deciding, I examined \ tried many options: HTML + JS (when I developed the concept), C # and GDI, C ++ and GDI, C # and XNA. All of these options did not allow to achieve the necessary performance (real-time rendering of more than 50,000 particles), so I began to consider more serious options. The first thing that came to mind was DirectDraw, but nobody has been developing it for a long time, so the choice fell on Direct3D. You could use OpenGL, but D3D is somehow closer to me.

0. Concept and requirements


The system will draw and animate particles. We will produce animation according to a certain formula (as an example, I used the Law of universal gravitation). You can interact with the system from outside, transmitting some data in real time.

Requirements.

We want our particle system to be sufficiently productive so that it can be used for real-time rendering, it must be flexible in setting, and it allows the use of sprites, various effects and post-effects.

Let's go in order:
1. Performance. Perhaps something faster than C \ C ++ will be difficult to find, and Direct3D is widely used in the development of computer games. We definitely have enough of its capabilities.
2. Real-time rendering. Actually Direct3D (OpenGL) is used for this. The selected candidate is suitable.
3. Flexibility in customization. DirectX has such a wonderful thing as shaders. You can implement anything without rewriting anything else but them.
4. Sprites. In DirectX, they are fairly easy to use. Fits.
5. Effects, post effects. To implement this, shaders will help us.

Now about the subtleties that must be considered in order to achieve sufficient performance. Because Since the number of particles is very large, it is better to submit them for rendering in one piece, so as not to lose performance due to the huge number of calls to the rendering functions. Well, memory also needs to be taken care of, so the best option would be to store data in the form of an array of points.

We solved all theoretical questions, now we will pass to implementation.

1. Initializing Direct3D and creating a camera


To work, we need the development environment \ compiler and DirectX SDK itself.
The first thing to do is create a Direct3D9 object, after the output device and a window where everything will be displayed.

Hidden text
// Создание и регистрация класса окна
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); // И буфер глубины тоже


In the code above, we create a regular window in which rendering will take place. Next is the Direct3D object. And finally, the device object, which we will use for drawing.

A few words about hardware acceleration. Many calculations can be done using the processor, emulating a video card, but since an ordinary processor is not very suitable for these purposes (in it, at best, 4 cores, and the video card uses dozens, or even hundreds), then this will affect performance. In some cases, very much. Therefore, it is better to use hardware acceleration if possible.

Also, do not forget about the installation of projection matrices and cameras. In short, the projection matrix is ​​used to convert 3D data to 2D, and the camera describes what we see and where we look.

But here I will introduce one simplification, the orthogonal projection matrix, because in fact, our particles are flat points, and they do not need special calculations of perspective, we can do without a camera. In the orthogonal projection, Z is not actually taken into account, and objects do not change size depending on the position in space.

// Инициализация матриц
	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. Creating particles and a buffer for them


We made all the preparations, it remains only to create particles and start drawing.

struct VertexData
{
	float x,y,z;
};

struct Particle
{
	float x, y, vx, vy;
};
std::deque<Particle> particles;

VertexData is used to store particle data in the GPU (vertex buffer), and contains the coordinates of our particle in space. This structure has a special format, and in fact the graphics processor will take from it information about what and where to draw.
Particle will represent our particle, and contains coordinates and speed.
In particles , information about all our particles will be stored. We will use this information to calculate particle motion.
Hidden text
//Заполняем внутренний массив сведениями о частицах
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);


I will comment on some points.
When creating the buffer, we pass the parameter D3DUSAGE_WRITEONLY , telling the GPU that we will not read data from the buffer. This will allow the GPU to make the necessary optimizations, and increase the speed of rendering.
VertexDeclaration is usually used to work with shaders. If shaders are not required, then you can do without creating this object.

3. Particle rendering


Now the particles need to be drawn. This is done very simply:
// Очищаем экранный буфер
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();

One important note: BeginScene () needs to be called every time before the start of drawing, and EndScene () after the end.

Animation


And of course, without animation, otherwise what kind of particle system it is. As an example, I used the Law of gravity.
Hidden text
// Получаем координаты курсора
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 distSquare;

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;

	distSquare= pow( x - mx, 2 ) + pow( y - my, 2 );
	if( dist < 20 )
	{
		force = 0;
	}
	else
	{
		force = G / distSquare;
	}

	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();


When locking the buffer, I specified the D3DLOCK_DISCARD flag , it allows the GPU to continue drawing particles from the old buffer. At the same time, they return a new one to us. This little trick reduces downtime.

On this, the first part of the article came to an end. The next part will describe the texturing of particles, vertex and pixel shaders, as well as effects and post-effects. Because же в конце 2-ой части вы увидите ссылки на демонстрацию и её полный исходный код.