일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 토이 프로젝트
- 게임 개발
- 1인 개발
- 합격
- 게임제작
- FPS
- Vampire Survivors
- 1인 게임 개발
- Pong
- 3회차
- 정처기
- 프로그래머스 #최소힙 #우선순위 큐
- 게임 제작
- 유니티3d
- 1인 게임 제작
- unity3d
- 1인 게임
- 게임
- Unity2D
- Unity
- 유니티 3D
- 정처기 필기
- 유니티
- portal
- Unity #Unity2D #Portal
- 필기
- 퐁
- 자바스크립트
- 자바스크립트 게임
- 정보처리기사
- Today
- Total
Coding Feature.
SFML) 모래와 물이 떨어지는 시뮬레이션 구현하기 in C++ 본문
유튜브에서 우연히 게임 'Noita'의 파티클 물리 엔진과 효과에 대해서 설명하고 구현하는 법을 알려주는 영상을 찾아보게되었다.
https://www.youtube.com/watch?v=VLZjd_Y1gJ8&t=60s
noita는 픽셀 단위의 파티클을 가지고 물, 모래, 불과 같은 물리적인 요소들을 효과적으로 활용하는 게임이다.
모래가 떨어지는 과정을 픽셀 단위의 그래픽으로 표현하기 위해서 "cellular automata", 또는 '세포 자동자' 라는 개념에 대해 공부해보았다.
https://ko.wikipedia.org/wiki/%EC%84%B8%ED%8F%AC_%EC%9E%90%EB%8F%99%EC%9E%90
각 세포에 대하여, "이웃들"이라 부르는 세포들은 그 세포에 대한 관계로 정의하는데, 예를 들어 그 세포에 대해 모든 방향으로 한 칸씩 떨어져 있는 세포들이라는 식으로 하면 된다. 시간 t=0 일 때의 각각의 세포의 상태를 지정해놓고 이를 초기 상태라고 한다. 새로운 "세대"(시간 t가 그 다음 자연수)는 고정된 "규칙"에 의해 이전 세대로부터 만들어지는데, 규칙은 각 세포와 그 이웃들의 상태에 따라 그 세포의 새로운 상태가 지정하는 수학적인 함수이다.
- 위키백과.
즉 각 세포들은 이웃하는 다른 세포들과의 관계에 의해서 다음 세대로 갈 때 그 상태가 바뀌는 과정을 다루는 모형이라고 이해했다.
대표적인 모형으로는 수학자 콘웨이의 라이프 게임이 있다고 한다.
모래나 물과 같은 물리 현상을 그래픽으로 구현하기 위해 세포 자동자의 개념을 그대로 이용할 수 있다고 한다.
모래와 같은 경우에는, 각 모래의 픽셀에 대해서 자신의 바로 아래에 있는 세 가지 픽셀을 탐색하게 된다.
차례대로
1. 자신의 바로 아래 픽셀이 비어있는 곳.
2. 1번이 없다면 자신의 아래, 왼쪽이 비어있는 곳.
3. 2번도 없다면 자신의 아래, 오른쪽이 비어있는 곳.
을 탐색하게 되고 그 자리에 모래가 이동한다고 한다.
실제로 코드를 짜는데 아래의 깃허브 소스 코드가 많은 도움이 되었다.
https://github.com/leonrode/falling-sand/tree/main
다만 위 소스 코드는 world 클래스에 대해서 현재 world, tick 이전의 world 두 가지 정보를 private으로 선언했지만,
위에서 설명한 로직을 체킹할 때는 world의 가장 아래, 그리고 왼쪽의 픽셀부터 업데이트를 한다면 그럴 필요가 없을 것 같아 현재 world에 대한 변수만 선언해보았다.
그리고 world의 가장자리에서 로직을 체킹할 때 범위를 벗어나는 경우에 대해서 이전에 DFS, BFS를 공부하면서 썼던,
dx, dy 배열에 대해서 for 문을 돌리는 방식을 사용했다. 그 방법이 훨씬 직관적일 거라고 생각했기 때문이다.
#include <SFML/Graphics.hpp>
#include <iostream>
// window 사이즈
#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 800
#define SAND_CELL 30
#define EMPTY_CELL 0
// 각 모래 파티클의 픽셀 사이즈.
#define SAND_SIZE 5
// for문을 이용한 조건 체크 위한 배열.
int dx[3] = { 0, -1, 1 };
sf::RenderWindow window(sf::VideoMode(WINDOW_WIDTH, WINDOW_HEIGHT), "Sand Simulator");
class World {
private:
// world는 실제 window 사이즈를 모래 크기만큼 나눈 사이즈의 grid.
static const int world_width = WINDOW_WIDTH / SAND_SIZE, world_height = WINDOW_HEIGHT / SAND_SIZE;
int world_grid[world_height][world_width];
public:
World() {
// 모든 셀을 empty로 초기화.
for (int i = 0; i < world_height; i++) {
for (int j = 0; j < world_width; j++) {
world_grid[i][j] = EMPTY_CELL;
}
}
}
// world에 존재하는 모든 모래 파티클의 연산. world의 가장 아래에서부터 왼쪽부터 차례대로 연산.
void UpdateWorld() {
for (int i = world_height - 1; i >= 0; i--) {
for (int j = 0; j < world_width; j++) {
if (world_grid[i][j] == SAND_CELL) {
for (int k = 0; k < 3; k++) {
// check_x, y에는 각 체크할 cell의 world 좌표가 입력됨.
// 차례대로 1. 바로 아래, 2. 아래 왼쪽, 3. 아래 오른쪽.
int check_x = j + dx[k];
int check_y = i + 1;
// 체크할 cell 좌표가 범위 밖일 경우 예외.
if (check_x < 0 || check_x >= world_width || check_y >= world_height)
continue;
// 체크할 cell 좌표가 비어있다.
if (world_grid[check_y][check_x] == EMPTY_CELL) {
world_grid[check_y][check_x] = SAND_CELL;
world_grid[i][j] = EMPTY_CELL;
break;
}
}
}
}
}
}
// window에 draw 연산 수행.
void DrawWorld() {
for (int i = 0; i < world_height; i++) {
for (int j = 0; j < world_width; j++) {
if (world_grid[i][j] == SAND_CELL) {
sf::RectangleShape rect;
rect.setFillColor(sf::Color(189, 156, 38));
rect.setSize(sf::Vector2f(SAND_SIZE, SAND_SIZE));
rect.setPosition(sf::Vector2f(j * SAND_SIZE, i * SAND_SIZE));
window.draw(rect);
}
}
}
}
// postion은 window 기준 좌표. 이를 world 좌표계로 변환하고 모래 입력.
void PutSand(sf::Vector2i position) {
world_grid[position.y / SAND_SIZE][position.x / SAND_SIZE] = SAND_CELL;
}
};
int main() {
World world;
sf::Clock clock;
window.setFramerateLimit(120);
// update_rate의 값이 낮을수록 빠른 update.
const float update_rate = 0.005f;
// countdown_ms 를 update_rate로 초기화. 최대값.
float countdown_ms = update_rate;
while (window.isOpen()) {
window.clear(sf::Color::White);
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed)
window.close();
}
if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) {
sf::Vector2i mouse_position = sf::Mouse::getPosition(window);
world.PutSand(mouse_position);
}
// sec에는 프레임 간의 시간 간격이 저장. countdown_ms에 빼줌으로써 점진적으로 감소시킴.
float sec = clock.restart().asSeconds();
countdown_ms -= sec;
// countdown_ms 가 0보다 작아진다면 월드를 업데이트하고 다시 update_rate만큼 초기화(최댓값)
if (countdown_ms < 0.f) {
world.UpdateWorld();
countdown_ms = update_rate;
}
world.DrawWorld();
window.display();
}
return 0;
}
또한 World를 업데이트할 때 시스템의 연산 처리 속도와 무관하게 업데이트 할 수 있도록,
update_rate, countdown_ms, 그리고 clock 을 사용했다.
update_rate는 사용자가 직접 설정하며 월드가 업데이트되는 시간 간격을 설정한다.
그리고 처음에는 countdown_ms를 update_rate와 동일한 값을 가지게 하고 loop을 돌면서 countdown_ms를 sec = clock.restart().asSecond()만큼 점진적으로 감소시킨다.
이때 sec은 각 루프를 돌때마다 그 루프 사이의 시간 간격이 저장된다.
따라서 update_rate만큼의 지정된 시간이 흘러 countdown_ms가 0보다 작아진다면 월드를 업데이트하고 다시 countdown_ms를 update_rate 값으로 초기화한다.
위와 같은 과정으로 사용자의 컴퓨터 환경에 무관하게 동일한 프레임값을 가진 프로그램을 구현하는 것임을 조금 이해하게 되었다.
그 다음 물이 떨어지는 시뮬레이션을 구현해보기로 했다.
물은 모래와 비슷한 이동 조건을 가지고 있다.
차례대로
1. 자신의 바로 아래 픽셀이 비어있는 곳.
2. 1번이 없다면 자신의 아래, 왼쪽이 비어있는 곳.
3. 2번도 없다면 자신의 아래, 오른쪽이 비어있는 곳.
을 탐색하게 되고,
위 조건이 해당하지 않는다면
4. 자신의 바로 왼쪽이 비어있는 곳.
5. 자신의 오른쪽이 비어있는 곳.
까지 탐색을 하게 된다.
또한 만약 모래가 물 위에 있다면 모래가 물 아래로 가라앉는 조건을 모래 쪽에 추가해야 한다.
위 내용을 그대로 적용시켜보았다.
예상과는 달리 물이 좌우로 흐르지 않고 한쪽 방향(왼쪽)으로만 흐르는 것을 알 수 있었다.
이는 월드를 업데이트할때의 방식 때문이다.
애초에 업데이트를 위해 각 셀을 탐색할 때 가장 아래에서부터, 그리고 왼쪽부터 차례대로 탐색을 하도록 코드를 짰었다. 따라서 아래 3개의 셀이 모두 모래나 물로 차있고 바로 왼쪽까지 차있는 경우 현재 물 셀은
1) 오른쪽이 비어있으므로 오른쪽으로 이동하고,
2) 원래 있었던 자리는 비어있는 것으로 바뀌므로,
3) 다시 업데이트를 할때 원래 있었던 자리에 이전 물 셀이 다시 왼쪽으로 이동하여 채워진다는 것이다.
즉 업데이트를 할 때 마다 오른쪽으로 이동했던 물은 다시 왼쪽으로 이동하게 된다.
모래에서는 이런 문제가 발생하지 않지만 물은 같은 높이의 왼쪽과 오른쪽까지 확인하므로 이런 문제가 발생했다.
이를 해결하기 위해서 월드를 업데이트 하는 방식에 살짝 변형을 줘보았다.
가장 아래에서부터 업데이트를 진행하는 것은 동일하지만 왼쪽부터 차례대로가 아니라 동일한 높이에 대해서 x좌표를 랜덤하게 고른 뒤에 그 셀을 업데이트 하는 방식으로 바꾸어 보았다.
예를 들어 5X5 월드가 있을때
(1, 3) -> (2, 3) -> (3, 3) -> (4, 3) -> (5, 3) 이렇게 업데이트를 하는 대신
(2, 3) -> (3, 3) -> (5, 3) -> (1, 3) -> (4, 3) 이런 식으로 랜덤으로 셀을 확인하고 업데이트 하도록 하였다.
확실히 나아진 모습이다.
다만 아직도 물은 오른쪽보다 왼쪽으로 더 빨리 채워지는 경향을 확인할 수 있었다.
이는 아래 세 개의 셀을 확인하고 왼쪽부터 먼저 확인하는 알고리즘에는 변화가 없었기 때문이라고 추측했다.
따라서 물 셀을 업데이트할 경우 아래 세 개가 모두 채워져 있고 자신의 왼쪽 또는 오른쪽이 비어있는지 확인해야 할 경우, 번갈아가면서 한 번은 왼쪽 먼저, 그 다음에는 오른쪽 먼저 확인하는 알고리즘으로 바꿔주었다.
조금 더 물이 자연스럽게 흐르는 것을 확인할 수 있었다.
이번 프로젝트에서 만든 최종 코드는 다음과 같다.
#include <SFML/Graphics.hpp>
#include <iostream>
#include <algorithm>
#include <random>
// window 사이즈
#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 800
// 셀 코드 정의
#define SAND_CELL 30
#define WATER_CELL 60
#define EMPTY_CELL -1
// 각 모래 파티클의 픽셀 사이즈.
#define CELL_SIZE 5
// for문을 이용한 조건 체크 위한 배열.
int dx[5] = { 0, -1, 1, -1, 1};
int dy[5] = { 1, 1, 1, 0, 0 };
// 난수 생성.
std::random_device rd;
std::mt19937 g(rd());
sf::RenderWindow window(sf::VideoMode(WINDOW_WIDTH, WINDOW_HEIGHT), "Sand Simulator");
class World {
private:
// world는 실제 window 사이즈를 모래 크기만큼 나눈 사이즈의 grid.
static const int world_width = WINDOW_WIDTH / CELL_SIZE, world_height = WINDOW_HEIGHT / CELL_SIZE;
int world_grid[world_height][world_width];
public:
World() {
// 모든 셀을 empty로 초기화.
for (int i = 0; i < world_height; i++) {
for (int j = 0; j < world_width; j++) {
world_grid[i][j] = EMPTY_CELL;
}
}
}
// world에 존재하는 모든 모래 파티클의 연산. world의 가장 아래에서부터 왼쪽부터 차례대로 연산.
void UpdateWorld() {
int random_width_index[world_width];
for (int i = 0; i < world_width; i++) {
random_width_index[i] = i;
}
for (int i = world_height - 1; i >= 0; i--) {
int temp = 0;
// 같은 높이의 모든 셀에 대해서 랜덤하게 업데이트하기 위해 셔플 함수 사용.
std::shuffle(random_width_index, random_width_index + world_width, g);
for (int j = random_width_index[temp]; temp < world_width; j = random_width_index[temp++]) {
if (world_grid[i][j] == SAND_CELL) { // 모래의 경우.
for (int k = 0; k < 3; k++) {
// check_x, y에는 각 체크할 cell의 world 좌표가 입력됨.
// 차례대로 1. 바로 아래, 2. 아래 왼쪽, 3. 아래 오른쪽.
int check_x = j + dx[k];
int check_y = i + dy[k];
// 체크할 cell 좌표가 범위 밖일 경우 예외.
if (check_x < 0 || check_x >= world_width || check_y >= world_height)
continue;
// 체크할 cell 좌표가 비어있다.
if (world_grid[check_y][check_x] == EMPTY_CELL) {
world_grid[check_y][check_x] = SAND_CELL;
world_grid[i][j] = EMPTY_CELL;
break;
}else if (world_grid[check_y][check_x] == WATER_CELL) { // 물이 모래 아래에 있다.
world_grid[check_y][check_x] = SAND_CELL;
world_grid[i][j] = WATER_CELL;
break;
}
}
}
if (world_grid[i][j] == WATER_CELL) {
// 같은 위치에서 왼쪽부터 먼저 체크할지 오른쪽부터 먼저 체크할지 번갈아가면서 순서를 바꾼다.
int temp_dx = dx[3];
dx[3] = dx[4];
dx[4] = temp_dx;
for (int k = 0; k < 5; k++) {
// check_x, y에는 각 체크할 cell의 world 좌표가 입력됨.
// 차례대로 1. 바로 아래, 2. 아래 왼쪽, 3. 아래 오른쪽, 4. 바로 왼쪽, 5. 바로 오른쪽
int check_x = j + dx[k];
int check_y = i + dy[k];
// 체크할 cell 좌표가 범위 밖일 경우 예외.
if (check_x < 0 || check_x >= world_width || check_y >= world_height)
continue;
// 체크할 cell 좌표가 비어있다.
if (world_grid[check_y][check_x] == EMPTY_CELL) {
world_grid[check_y][check_x] = WATER_CELL;
world_grid[i][j] = EMPTY_CELL;
break;
}
}
}
}
}
}
// window에 draw 연산 수행.
void DrawWorld() {
for (int i = 0; i < world_height; i++) {
for (int j = 0; j < world_width; j++) {
if (world_grid[i][j] == SAND_CELL) {
sf::RectangleShape rect;
rect.setFillColor(sf::Color(189, 156, 38));
rect.setSize(sf::Vector2f(CELL_SIZE, CELL_SIZE));
rect.setPosition(sf::Vector2f(j * CELL_SIZE, i * CELL_SIZE));
window.draw(rect);
}
if (world_grid[i][j] == WATER_CELL) {
sf::RectangleShape rect;
rect.setFillColor(sf::Color::Blue);
rect.setSize(sf::Vector2f(CELL_SIZE, CELL_SIZE));
rect.setPosition(sf::Vector2f(j * CELL_SIZE, i * CELL_SIZE));
window.draw(rect);
}
}
}
}
// postion은 window 기준 좌표. 이를 world 좌표계로 변환하고 모래 입력.
void PutSand(sf::Vector2i position) {
world_grid[position.y / CELL_SIZE][position.x / CELL_SIZE] = SAND_CELL;
}
// postion은 window 기준 좌표. 이를 world 좌표계로 변환하고 물 입력.
void PutWater(sf::Vector2i position) {
world_grid[position.y / CELL_SIZE][position.x / CELL_SIZE] = WATER_CELL;
}
};
int main() {
World world;
sf::Clock clock;
window.setFramerateLimit(120);
srand(time(0));
// update_rate의 값이 낮을수록 빠른 update.
const float update_rate = 0.005f;
// countdown_ms 를 update_rate로 초기화. 최대값.
float countdown_ms = update_rate;
while (window.isOpen()) {
window.clear(sf::Color::White);
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed)
window.close();
}
// 마우스 왼쪽 클릭 시 모래 입력.
if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) {
sf::Vector2i mouse_position = sf::Mouse::getPosition(window);
world.PutSand(mouse_position);
}
// 마우스 오른쪽 클릭 시 물 입력.
if (sf::Mouse::isButtonPressed(sf::Mouse::Right)) {
sf::Vector2i mouse_position = sf::Mouse::getPosition(window);
world.PutWater(mouse_position);
}
// sec에는 프레임 간의 시간 간격이 저장. countdown_ms에 빼줌으로써 점진적으로 감소시킴.
float sec = clock.restart().asSeconds();
countdown_ms -= sec;
// countdown_ms 가 0보다 작아진다면 월드를 업데이트하고 다시 update_rate만큼 초기화(최댓값)
if (countdown_ms < 0.f) {
world.UpdateWorld();
countdown_ms = update_rate;
}
world.DrawWorld();
window.display();
}
return 0;
}
- 추가로 보완할 점.
1. 유체의 점성 추가. 흐르는 속도를 조절할 수 있도록 해본다.
2. 모래, 물 이외의 요소를 시뮬레이션 해본다. (연기, 불 등)
'Game Development > SFML' 카테고리의 다른 글
SFML) 정렬 알고리즘 그래픽으로 구현하기. (0) | 2023.09.03 |
---|---|
SFML로 DFS 시뮬레이션 해보기 in C++ (0) | 2023.09.02 |