пятница, 29 декабря 2023 г.

2d-коллайдер и его реализация на OpenCL

Улучшаем предыдущий пример. Теперь реализуем этот 2д-коллайдер на OpenCL. Учитывая, что нам надо будет параллельно вычислять и менять текущую позицию и вектор движения одновременно для всех обьектов, то использовать один массив обьектов будет проблематично, т.к. нужно будет использовать различные примитивы синхронизации, что безусловно пагубно скажется на производительности. Поэтому обход/модификация всех обьектов будет производиться следующим образом: один массив обьектов будет только для чтения, а второй только для записи и каждый тред на GPU будет записывать изменения в свою ячейку, не пересекаясь с другими.

  1. __kernel void collideAndUpdate(
  2. __global Ball* b1,
  3. __global Ball* b2,
  4. const int ballCnt,
  5. const int scrW,
  6. const int scrH,
  7. const double frameTimeMs)
  8. {
  9. // Get work-item identifiers.
  10. int i = get_global_id(0);
  11. Ball tmp = b1[i];
  12. for (int j = 0; j < ballCnt; j++)
  13. {
  14. // check collision between one ball to others,
  15. // but don't check collision to itself
  16. if (j != i)
  17. {
  18. Ball tmp2 = b1[j];
  19. tmp = checkCollision(tmp, tmp2);
  20. }
  21. }
  22.  
  23. // check borders
  24. if (tmp.pos.x <= 0 || tmp.pos.x >= scrW)
  25. {
  26. tmp.f.x *= -1;
  27. }
  28. if (tmp.pos.y <= 0 || tmp.pos.y >= scrH)
  29. {
  30. tmp.f.y *= -1;
  31. }
  32.  
  33. // update positions
  34. tmp.pos.x += tmp.f.x * frameTimeMs;
  35. tmp.pos.y += tmp.f.y * frameTimeMs;
  36.  
  37. b2[i] = tmp;
  38. }
Поскольку не получилось подключить C++ заголовочный файл в код ядра, то пришлось по сути переписывать все заново для OpenCL:

  1. Ball checkCollision(Ball b1, const Ball b2)
  2. {
  3. float2 p1 = (float2)(b1.pos.x, b1.pos.y);
  4. float2 p2 = (float2)(b2.pos.x, b2.pos.y);
  5. const float dist = getDistanceBetween(p1, p2);
  6. if (dist < b1.r + b2.r)
  7. {
  8. // direction to other ball
  9. float2 to2 = p2 - p1;
  10. float2 f = (float2)(b1.f.x, b1.f.y);
  11. // calculate dot product
  12. float dotProd = dot(f, to2);
  13. // if dot product is negative then force directed away from B ball
  14. // and we do nothing
  15. if (dotProd > 0)
  16. {
  17. // angle between normal and force (moving) vectors
  18. float angle = getAngleTo(f, to2);
  19. // the angle of incidence is equal to the angle of reflection
  20. f = rotVect(f, angle * 2);
  21. f = f * (-1);
  22. b1.f.x = f.x;
  23. b1.f.y = f.y;
  24. }
  25. }
  26. return b1;
  27. }
И структуру Ball и все векторные операции тоже пришлось переписывать. Потому что OpenCL компилятор и С++ компилятор отличаются очень сильно. Фактически настолько, что и там и там можно использовать только какие-то простые структуры и константы с дефайнами. Вот как выглядят векторные операции:

  1. float getDistanceBetween(float2 p1, float2 p2)
  2. {
  3. float2 p1p2 = p2 - p1;
  4. float dist = native_sqrt(p1p2[0] * p1p2[0] + p1p2[1] * p1p2[1]);
  5. return dist;
  6. }
  7.  
  8. float getLen(float2 p)
  9. {
  10. float len = getDistanceBetween((float2)(0, 0), p);
  11. return len;
  12. }
  13.  
  14. float getCrossProd(float2 p1, float2 p2)
  15. {
  16. float crossProd = p1[0] * p2[1] - p1[1] * p2[0];
  17. return crossProd;
  18. }
  19.  
  20. float getAngleTo(float2 p1, float2 p2)
  21. {
  22. return asin(getCrossProd(p1, p2) / (getLen(p1) * getLen(p2)));
  23. }
  24.  
  25. float2 rotVect(float2 v, float angle)
  26. {
  27. // first of all create rotation matrix
  28. float c = cos(angle);
  29. float s = sin(angle);
  30. float2 mr0 = { c, -s }; // first row
  31. float2 mr1 = { s, c }; // second row
  32. // get rotated vector by multiply matrix to vector
  33. float2 tmp;
  34. tmp[0] = (v[0] * mr0[0] + v[1] * mr0[1]);
  35. tmp[1] = (v[0] * mr1[0] + v[1] * mr1[1]);
  36. return tmp;
  37. }
Я не нашел как работать с матрицами, поэтому в качестве матрицы 2х2 я использовал просто два вектора float2, каждый из которых играет роль строки в матрице (строки 30-31). То есть довольно много заново написанного кода и если кто-то задумал перенести что-то с помощью OpenCL на видеокарту, то пусть имеют ввиду, что такой даже не копипасты, а полной переработки кода с учетом кучи нюансов будет очень много. 
Чтобы увидеть реальную разницу в производительности, понадобилось увеличить количество шариков с 500 до 20000 и уменьшить изх диаметр до 1, чтобы они все поместились. Результат:


Производительность на GPU при 20 тысячах шариков получилась 29.3 кадра в секунду, а на CPU всего лишь 1.6 кадра в секунду. Разница, как говорится, налицо! 

Тестовая платформа: Ryzen 3700X, 16GB RAM, RTX 3060 12GB

Весь код здесь.

среда, 27 декабря 2023 г.

Использование SDL2 + memake + простой самописный 2D-коллайдер

 Решил посмотреть на самые простые способы отображения графических примитивов в Windows и нашел библиотеку memake. В использовании достаточно простая и удобная, но зависит от библиотеки SDL. В примере для Visual Studio (я использую VS 2019 и всем советую использовать эту или новее) сразу идут собранные бинари SDL, так что все работает "из коробки", но настроено там все только для дебажной x86 версии, так что я пытался настроить для остальных (Release x86, Debug x64, Release x64), но не преуспел (слишком много всяких параметров менять руками) и решил сделать все через cmake, попутно разбираясь, а что же там не работает. Получил вот такой CMakeLists.txt:

  1. #
  2. cmake_minimum_required (VERSION 3.8)
  3.  
  4. project ("MemakePrj")
  5.  
  6. # Add source to this project's executable.
  7. add_executable (MemakePrj "main.cpp" "Memake/Memake.cpp" "Memake/Vector2d.cpp")
  8.  
  9. # SDL2 headers
  10. target_include_directories(MemakePrj PRIVATE "SDL2-2.0.14/include")
  11.  
  12. # add SDL_MAIN_HANDLED definition to avoid
  13. # "LNK2019 unresolved external symbol SDL_main referenced in function main_getcmdline"
  14. add_definitions( -DSDL_MAIN_HANDLED )
  15.  
  16. # SDL library folder
  17. set(SDL2_lib_folder "${PROJECT_SOURCE_DIR}/SDL2-2.0.14/lib")
  18.  
  19. message(${CMAKE_BUILD_TYPE})
  20.  
  21. # check 32 or 64 bits
  22. if(CMAKE_SIZEOF_VOID_P EQUAL 8)
  23. # 64 bits
  24. set(SDL2_lib_folder "${SDL2_lib_folder}/x64")
  25. elseif(CMAKE_SIZEOF_VOID_P EQUAL 4)
  26. # 32 bits
  27. set(SDL2_lib_folder "${SDL2_lib_folder}/x86")
  28. endif()
  29.  
  30. # link SDL2 static lib
  31. target_link_libraries(MemakePrj ${SDL2_lib_folder}/SDL2.lib)
  32. target_link_libraries(MemakePrj ${SDL2_lib_folder}/SDL2main.lib)
  33.  
  34. # copy dynamic lib to folder with executable file
  35. file(COPY ${SDL2_lib_folder}/SDL2.dll DESTINATION ${PROJECT_BINARY_DIR})
Теперь по порядку. В строке 7 добавляю к main.cpp еще два cpp-файла из библиотеки memake (остальное там - заголовочные файлы) и вся библиотека будет таким образом включена в исполнимый файл. Можно было сделать отдельный CMakeLists.txt для папки memake, чтобы вся библиотека подтягивалась и собиралась отдельно, но мне было лень и теперь все у меня одним куском. В строке 10 добавляю заголовки для SDL, а в строке 14 добавляю специальный дефинишен SDL_MAIN_HANDLED, потому что без него будет ошибка компиляции "LNK2019 unresolved external symbol SDL_main ...". В строке 17 указываем папку с бинарями библиотеки SDL, а в строках 22-28 определяем, какую версию бинарных файлов SDL нам надо использовать x86 или x64. В строках 31 и 32 собственно подключаем эти библиотеки к проекту. В строке 35 очень интересный момент - копирование SDL2.dll в папку с исполняемым файлом. Оказывается, без этой dll оно все не будет работать и в солюшене для Visual Studio был просто добавлен путь в переменную PATH для проекта. Я не нашел как сделать что-то такое же для cmake, поэтому просто скопировал файл библиотеки в папку с exe-файлом (строка 35). На самом деле составлять CMakeLists.txt для проекта - это целое дело, сопоставимое с написанием кода, но это все же вспомогательная задача, от которой нужно только одно: чтобы все собиралось и работало. Так что я не упорствовал в поиске каких-то сильно красивых, изящных и правильных решений: работает, выглядит понятно - ну и хорошо.
То ли дело посмотреть как работает "коллижн менеджер" в оригинальном примере - а он работает очень просто и можно даже сказать примитивно, но справляется со своей задачей (демонстрация работы библиотеки): 

  1. void checkCollision(Ball& b)
  2. {
  3. float distX = x - b.x;
  4. float distY = y - b.y;
  5.  
  6. float distance = sqrt((distX * distX) + (distY * distY));
  7. if (distance < r + b.r)
  8. {
  9. dx *= -1;
  10. dy *= -1;
  11. }
  12. }
Если расстояниеот одного до другого шарика меньше, чем сумма радиусов обоих, то шарик отлетает в противоположную сторону (направления движения по обоим осям умножаются на -1). Простенько и со вкусом. В результате все шарики летают под углом, кратным 45 градусов: 


Я решил заморочиться и все же учесть правило "угол падения равен углу отражения", для чего даже написал "библиотеку" операций для двумерного пространства. В результате вот такой получился код:

  1. void checkCollision(Ball& b)
  2. {
  3. float dist = pos.distanceTo(b.pos);
  4. if (dist < r + b.r)
  5. {
  6. // direction to other ball
  7. Point2f toB = b.pos - pos;
  8. // calculate dot product
  9. float dotProd = f.dotProduct(toB);
  10. // if dot product is negative then force directed away from B ball
  11. // and we do nothing
  12. if (dotProd > 0)
  13. {
  14. // angle between normal and force (moving) vectors
  15. float angle = f.angleTo(toB);
  16. // the angle of incidence is equal to the angle of reflection
  17. f = Mat2x2f().rot(angle * 2) * f;
  18. f = f * (-1.f);
  19. }
  20. }
  21. }
Если вкратце, то мы тут тоже сначала измеряем расстояние от этого до другого шарика, а потом вычисляем вектор toB от центра этого до центра другого шарика, после чего в строке 9 вычисляем dot product вектора движения f на toB (что есть проекция f на toB) и в случае, если он больше нуля (то есть направлен на другой шарик), то происходит "отскок", а если меньше нуля, то значит наш шарик и так двигается прочь от другого шарика и делать ничего не надо. Отскок мы вычисляем следующим образом: вычисляем угол между вектором движения и вектором направления на центр другого шарика (строка 15), после чего доворачиваем (путем умножения на матрицу поворота) вокруг оси направления вектор движения на этот двойной угол и потом умножаем его на -1 и таким образом реализуем правило "угол падения равен углу отражения". Для большей наглядности вот картинка:


Вот тут вертикальная ось - это как раз вектор toB на центр другого шарика, а горизонтальная линия - это касательная к точке, где произошел контакт. Получилось вот так:


Уже гораздо более интересно! Но пришлось, конечно, вспомнить векторную и матричную математику.

Весь код здесь.