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

Для начала стоит сказать почему именно C++ и DirectX9, а не, скажем, XNA, или вообще GDI. Перед тем как определиться, я рассмотрел\попробовал много вариантов: HTML+JS (когда разрабатывал концепцию), С# и GDI, C++ и GDI, С# и XNA. Все из перечисленных вариантов не позволили достичь необходимой производительности (реал-тайм рендеринг более 50000 частиц), поэтому я стал рассматривать более серьезные варианты. Первое что пришло в голову было DirectDraw, но его давно никто не разрабатывает, поэтому выбор пал на Direct3D. Можно было использовать и OpenGL, но D3D мне как-то ближе.
Система будет рисовать и анимировать частицы. Анимацию будем производить по некой формуле (в качестве примера я использовал Закон всемирного тяготения). С системой можно взаимодействовать из вне, передавая какие-то данные в реальном времени.
Нам хочется, чтобы наша система частиц была достаточно производительной, чтобы её можно было использовать для отрисовки в реальном времени, должна быть гибкой в настройке, позволяла применять спрайты, различные эффекты и пост-эффекты.
Пойдем по-порядку:
1. Производительность. Пожалуй, чего-то быстрее чем C\C++ будет сложно найти, да и Direct3D широко применяется при разработке компьютерных игр. Нам его возможностей точно хватит.
2. Отрисовка в реальном времени. Собственно Direct3D (OpenGL) для этого и используют. Выбранный кандидат подходит.
3. Гибкость в настройке. В DirectX есть такая замечательная вещь, как шейдеры. Можно реализовать что угодно, не переписывая больше ничего, кроме них.
4. Спрайты. В DirectX ими достаточно легко пользоваться. Подходит.
5. Эффекты, пост-эффекты. Для реализации этого нам помогут шейдеры.
Теперь о тонкостях, которые необходимо учесть для того, чтобы достичь достаточной производительности. Т.к. количество частиц очень велико, то на отрисовку их подавать лучше одним куском, дабы не терять в производительности из-за огромного количества вызовов функций отрисовки. Ну и о памяти тоже нужно позаботиться, поэтому оптимальным вариантом будет хранение данных в виде массива точек.
Все теоретические вопросы мы решили, теперь перейдем к реализации.
Для работы нам понадобится собственно среда разработки\компилятор и DirectX SDK
Первое, что нужно сделать это создать объект Direct3D9, после устройство для вывода и окно, где и будет все отображаться.
В коде выше мы создаем обычное окно, в котором будет происходить отрисовка. Далее объект Direct3D. И наконец объект устройства, который мы и будем использовать для рисования.
Пару слов о аппаратном ускорении. Многие вычисления можно производить с помощью процессора, эмулируя видеокарту, но т.к. обычный процессор не очень подходит для этих целей (в нем, в лучшем случае, 4 ядра, а в видеокарте используется десятки, а то и сотни), то это скажется на быстродействии. В некоторых случаях очень сильно. Поэтому по возможности лучше использовать аппаратное ускорение.
Еще следует не забывать об установки матриц проекции и камеры. Если коротко, то матрица проекции используется для преобразование 3D данных в 2D, а камера описывает то, что мы видим и куда смотрим.
Но здесь я внесу одно упрощение, ортогональную матрицу проекции, т.к. фактически наши частицы плоские точки, и им не нужны особые расчеты перспективы, мы можем обойтись и без камеры. В ортогональной проекции Z фактически не учитывается, и объекты не изменяют размер в зависимости от положения в пространстве.
Все приготовления мы выполнили, осталось лишь создать частицы и приступить к рисованию.
VertexData используется для хранения данных о частице в GPU (вершинный буфер), и содержит координаты нашей частицы в пространстве. Эта структура имеет особый формат, и фактически графический процессор будет брать из нее сведения что и где рисовать.
Particle будет представлять нашу частицу, и содержит координаты и скорость.
В particles же будут хранится сведения о всех наших частицах. Этой информацией мы будем пользоваться для расчетов движения частиц.
Прокомментирую некоторые моменты.
При создание буфера, мы передаем параметр D3DUSAGE_WRITEONLY, говоря GPU, что мы не будем читать данные из буфера. Это позволит графическому процессору произвести необходимые оптимизации, и увеличит скорость рендеринга.
VertexDeclaration обычно используют для работы с шейдерами. Если шейдеры не требуются, то можно обойтись без создания этого объекта.
Теперь частицы необходимо нарисовать. Делается это очень просто:
Одно важно замечание: BeginScene() необходимо вызывать каждый раз перед началом рисования, а EndScene() после окончания.
Ну и конечно никак без анимации, иначе какая же это система частиц. В качестве примера я использовал Закон всемирного тяготения.
При блокировке буфера, я указал флаг D3DLOCK_DISCARD, он позволяет продолжить графическому процессору отрисовку частиц из старого буфера. Нам при этом возвращают новый. Эта маленькая хитрость уменьшает простои оборудования.
На этом первая часть статьи подошла к концу. В следующей части будет описано текстурирование частиц, вершинные и пиксельные шейдеры, а так же эффекты и пост-эффекты. Так же в конце 2-ой части вы увидите ссылки на демонстрацию и её полный исходный код.
Вторая часть доступна здесь.
Пример одного из кадров визуализации (кликабельно):

Для начала стоит сказать почему именно C++ и DirectX9, а не, скажем, XNA, или вообще GDI. Перед тем как определиться, я рассмотрел\попробовал много вариантов: HTML+JS (когда разрабатывал концепцию), С# и GDI, C++ и GDI, С# и XNA. Все из перечисленных вариантов не позволили достичь необходимой производительности (реал-тайм рендеринг более 50000 частиц), поэтому я стал рассматривать более серьезные варианты. Первое что пришло в голову было DirectDraw, но его давно никто не разрабатывает, поэтому выбор пал на Direct3D. Можно было использовать и OpenGL, но D3D мне как-то ближе.
0. Концепция и требования
Система будет рисовать и анимировать частицы. Анимацию будем производить по некой формуле (в качестве примера я использовал Закон всемирного тяготения). С системой можно взаимодействовать из вне, передавая какие-то данные в реальном времени.
Требования.
Нам хочется, чтобы наша система частиц была достаточно производительной, чтобы её можно было использовать для отрисовки в реальном времени, должна быть гибкой в настройке, позволяла применять спрайты, различные эффекты и пост-эффекты.
Пойдем по-порядку:
1. Производительность. Пожалуй, чего-то быстрее чем C\C++ будет сложно найти, да и 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 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();
При блокировке буфера, я указал флаг D3DLOCK_DISCARD, он позволяет продолжить графическому процессору отрисовку частиц из старого буфера. Нам при этом возвращают новый. Эта маленькая хитрость уменьшает простои оборудования.
На этом первая часть статьи подошла к концу. В следующей части будет описано текстурирование частиц, вершинные и пиксельные шейдеры, а так же эффекты и пост-эффекты. Так же в конце 2-ой части вы увидите ссылки на демонстрацию и её полный исходный код.
