-
cellular automata: 간단한 모래 시뮬레이터 만들기Apple🍎/Metal 2025. 3. 24. 21:18
지난 시간에는 간단한 규칙으로 복잡한 시스템을 모델링하는 cellular automata가 뭔지에 대해서 알아보았는데요.
이번에는 이러한 cellular automata 개념을 사용해 간단한 모래 시뮬레이터를 만들어보도록 하겠습니다.
1. 격자 그리기
일단 시뮬레이션할 모래의 무대가 될 격자를 먼저 그려보도록 하겠습니다.
SwiftUI의 Canvas를 사용해서 각각의 픽셀이 10 * 10 사이즈인 40 * 40 크기의 격자를 만들어보겠습니다.
- 코드
더보기import SwiftUI struct ContentView: View { /// Celluar automata를 구성하는 격자의 속성(수, 크기)를 상수로 정의 let columns = 40 let rows = 40 let cellSize: CGFloat = 10 var body: some View { VStack { Text("격자 화면") .font(.largeTitle) .padding() /// Canvas는 SwiftUI에서 저수준 그래픽 작업을 지원하는 View /// context: Canvas 가 정의하는 View상에 그래픽 작업(그리기)를 수행하는 객체 /// size: Canvas가 현재 View 계층에서 차지하는 size를 반환 Canvas { context, size in // 격자의 가로선을 그립니다. (row 수 + 1) for row in 0...rows { // 현재 행의 y 좌표 값을 계산 let y = CGFloat(row) * cellSize // 현재 그을 가로선의 시작점과 끝점을 정의 // 선의 왼쪽 끝 (x=0, y=현재행위치) let startPoint = CGPoint(x: 0, y: y) // 선의 오른쪽 끝 (x=격자의총너비, y=현재행위치) let endPoint = CGPoint(x: CGFloat(columns) * cellSize, y: y) // 선을 그리기 위한 경로 객체 생성 var path = Path() // 경로의 시작점 설정 path.move(to: startPoint) // 현재 위치에서 지정된 점까지 직선을 추가 path.addLine(to: endPoint) // context를 사용 canvas 상에 // 앞서 설정한 경로를 그린다. context.stroke(path, with: .color(.gray), lineWidth: 0.5) } // 격자의 세로선을 그립니다. (column 수 + 1) for column in 0...columns { // 현재 열의 x 좌표값 계산 let x = CGFloat(column) * cellSize // 세로선의 시작점과 끝점을 정의 // 선의 위쪽 끝 (x=현재열위치, y=0) let startPoint = CGPoint(x: x, y: 0) // 선의 아래쪽 끝 (x=현재열위치, y=격자의총높이) let endPoint = CGPoint(x: x, y: CGFloat(rows) * cellSize) // 선을 그리기 위한 경로 객체 생성 var path = Path() // 경로의 시작점 설정 path.move(to: startPoint) // 현재 위치에서 지정된 점까지 직선을 추가 path.addLine(to: endPoint) // context를 사용 canvas 상에 // 앞서 설정한 경로를 그린다. context.stroke(path, with: .color(.gray), lineWidth: 0.5) } } // frame을 이용해 Canvas size 지정하기 .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize) // Canvas 배경색상 설정 .background(Color.black) // Canvas 테두리 설정 .border(Color.white) } } }
2. 배열로 각 Cell 상태 값 관리하기
앞서 그린 Grid 격자 위에서 모래들을 움직이게 하려면 각 Cell의 상태 값(비었는지 모래인지)를 관리해야합니다.
따라서 40 * 40 자리 2차원 배열을 만들어 각 Cell의 상태를 관리하도록 하였습니다.
격자를 그린 후에 grid 배열을 순회하면서 모래인경우 해당 cell을 칠합니다.
다음은 10행 20열 상에 모래를 하나 배치한 모습입니다.
- 코드
더보기import SwiftUI struct ContentView: View { let columns = 40 let rows = 40 let cellSize: CGFloat = 10 // Grid의 각 cell의 상태 값 : 0 = empty, 1 = sand @State private var grid = Array(repeating: Array(repeating: 0, count: 40), count: 40) var body: some View { VStack { Text("모래 칠하기") .font(.largeTitle) .padding() Canvas { context, size in // 그리드 라인 그리기 for row in 0...rows { let y = CGFloat(row) * cellSize let startPoint = CGPoint(x: 0, y: y) let endPoint = CGPoint(x: CGFloat(columns) * cellSize, y: y) var path = Path() path.move(to: startPoint) path.addLine(to: endPoint) context.stroke(path, with: .color(.gray), lineWidth: 0.5) } for column in 0...columns { let x = CGFloat(column) * cellSize let startPoint = CGPoint(x: x, y: 0) let endPoint = CGPoint(x: x, y: CGFloat(rows) * cellSize) var path = Path() path.move(to: startPoint) path.addLine(to: endPoint) context.stroke(path, with: .color(.gray), lineWidth: 0.5) } // 모래 그리기 for row in 0..<grid.count { for column in 0..<(grid[row].count) { if grid[row][column] == 1 { let rect = CGRect( x: CGFloat(column) * cellSize, y: CGFloat(row) * cellSize, width: cellSize, height: cellSize ) context.fill(Path(rect), with: .color(.green)) } } } } .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize) .background(Color.black) .border(Color.white) } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { initGrid() } } } private func initGrid() { var newGrid = Array(repeating: Array(repeating: 0, count: rows), count: columns) newGrid[10][20] = 1 grid = newGrid } }
기존에 격자와 모래를 따로 그리는 방법에서 한번에 그리는 형태로 개선
struct ContentView: View { let columns = 40 let rows = 40 let cellSize: CGFloat = 10 // Grid의 각 cell의 상태 값 : 0 = empty, 1 = sand @State private var grid = Array(repeating: Array(repeating: 0, count: 40), count: 40) var body: some View { VStack { Text("모래 칠하기") .font(.largeTitle) .padding() Canvas { context, size in // 모래와 그리드를 한 번에 그리기 for row in 0..<rows { let y = CGFloat(row) * cellSize for column in 0..<columns { let x = CGFloat(column) * cellSize let rect = CGRect( x: x, y: y, width: cellSize, height: cellSize ) // 모래 채우기 (더 먼저 그려서 그리드 라인이 위에 오도록) if grid[row][column] == 1 { context.fill(Path(rect), with: .color(.green)) } // 셀 테두리 그리기 (각 셀의 테두리만 그림) var cellPath = Path() cellPath.addRect(rect) context.stroke(cellPath, with: .color(.gray), lineWidth: 0.5) } } } .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize) .background(Color.black) .border(Color.white) } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { initGrid() } } } private func initGrid() { var newGrid = Array(repeating: Array(repeating: 0, count: columns), count: rows) newGrid[10][20] = 1 grid = newGrid } }
3. 모래 움직임 시뮬레이션
이제 모래가 밑으로 떨어지는 것을 시뮬레이션 해보도록 하겠습니다.
시뮬레이션은 다음과 같은 방법으로 진행됩니다.
1. Grid가 관리하는 상태 값 배열을 모두 순회하며 규칙에 따라 상태 값을 업데이트 합니다. ( 해당 과정을 1프레임으로 취급합니다.)
2. 타이머를 이용해 1초마다 1프레임을 넘깁니다. ( 따라서 1초마다 배열의 모든 상태 값들을 업데이트하고 이를 화면에 반영합니다. )
3. 모래는 다음과 같은 규칙으로 동작합니다.
- 아래가 비어있으면 아래로 떨어집니다.
- 아래가 막혀있으면 왼쪽 아래나 오른쪽 아래로 이동을 시도합니다.
- 모든 방향이 막혀있거나 맨 아래 땅에 도달하면 그 자리에 멈춥니다.
- 코드
더보기import SwiftUI struct ContentView: View { let columns = 40 let rows = 40 let cellSize: CGFloat = 10 // Grid의 각 cell의 상태 값 : 0 = empty, 1 = sand @State private var grid = Array(repeating: Array(repeating: 0, count: 40), count: 40) // Timer for simulation updates @State private var timer: Timer? @State private var isPaused: Bool = false var body: some View { VStack { Text("모래 떨어뜨리기") .font(.largeTitle) .padding() Canvas { context, size in // 모래와 그리드를 한 번에 그리기 for row in 0..<rows { let y = CGFloat(row) * cellSize for column in 0..<columns { let x = CGFloat(column) * cellSize let rect = CGRect( x: x, y: y, width: cellSize, height: cellSize ) // 모래 채우기 (더 먼저 그려서 그리드 라인이 위에 오도록) if grid[row][column] == 1 { context.fill(Path(rect), with: .color(.green)) } // 셀 테두리 그리기 (각 셀의 테두리만 그림) var cellPath = Path() cellPath.addRect(rect) context.stroke(cellPath, with: .color(.gray), lineWidth: 0.5) } } } .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize) .background(Color.black) .border(Color.white) HStack{ Button(isPaused ? "Resume" :"Pause"){ isPaused.toggle() if isPaused{ timer?.invalidate() }else { startSimulation() } } } } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { initGrid() startSimulation() } } .onDisappear { // 타이머 정리 timer?.invalidate() timer = nil } } private func initGrid() { var newGrid = Array(repeating: Array(repeating: 0, count: columns), count: rows) newGrid[10][20] = 1 grid = newGrid } private func startSimulation() { timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in updateGrid() }) } func updateGrid() { // 아래에서부터 위로 검사해야 모래가 한 프레임에 여러 칸 떨어지는 것을 방지할 수 있음 for row in (0..<rows-1).reversed() { for column in 0..<columns { if grid[row][column] == 1 { // 아래 칸이 비어있는지 확인 if grid[row+1][column] == 0 { // 모래를 아래로 이동 grid[row][column] = 0 grid[row+1][column] = 1 } // 아래칸이 막혀 있다면 아래 대각선 방향으로 이동 시도 else if column > 0 && grid[row+1][column-1] == 0 { // 왼쪽 아래 대각선으로 이동 grid[row][column] = 0 grid[row+1][column-1] = 1 } else if column < columns-1 && grid[row+1][column+1] == 0 { // 오른쪽 아래 대각선으로 이동 grid[row][column] = 0 grid[row+1][column+1] = 1 } // 모래가 있지만 위의 조건에 모두 해당하지 않은 경우 아무것도 하지 않음 (멈춤) } } } } }
4. 사용자 제스쳐를 추가
드래그를 통해 사용자가 선택한 지점의 그리드에 모래를 생성하도록 합니다.
새로 생성된 모래는 위에서 정한 규칙에 따라 이동하며 바닥면에 쌓이게 됩니다.
- 코드
더보기import SwiftUI struct ContentView: View { let columns = 40 let rows = 40 let cellSize: CGFloat = 10 // Grid의 각 cell의 상태 값 : 0 = empty, 1 = sand @State private var grid = Array(repeating: Array(repeating: 0, count: 40), count: 40) // Timer for simulation updates @State private var timer: Timer? @State private var isPaused: Bool = false var body: some View { VStack { Text("사용자 인터렉션 추가") .font(.largeTitle) .padding() Canvas { context, size in // 모래와 그리드를 한 번에 그리기 for row in 0..<rows { let y = CGFloat(row) * cellSize for column in 0..<columns { let x = CGFloat(column) * cellSize let rect = CGRect( x: x, y: y, width: cellSize, height: cellSize ) // 모래 채우기 (더 먼저 그려서 그리드 라인이 위에 오도록) if grid[row][column] == 1 { context.fill(Path(rect), with: .color(.green)) } // 셀 테두리 그리기 (각 셀의 테두리만 그림) var cellPath = Path() cellPath.addRect(rect) context.stroke(cellPath, with: .color(.gray), lineWidth: 0.5) } } } .frame(width: CGFloat(columns) * cellSize, height: CGFloat(rows) * cellSize) .background(Color.black) .border(Color.white) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in // 드래그 위치에 모래 추가 let x = Int(value.location.x / cellSize) let y = Int(value.location.y / cellSize) if x >= 0 && x < columns && y >= 0 && y < rows { grid[y][x] = 1 } } ) HStack{ Button(isPaused ? "Resume" :"Pause"){ isPaused.toggle() if isPaused{ timer?.invalidate() }else { startSimulation() } } } } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { initGrid() startSimulation() } } .onDisappear { // 타이머 정리 timer?.invalidate() timer = nil } } private func initGrid() { var newGrid = Array(repeating: Array(repeating: 0, count: columns), count: rows) newGrid[10][20] = 1 grid = newGrid } private func startSimulation() { timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { _ in updateGrid() }) } func updateGrid() { // 아래에서부터 위로 검사해야 모래가 한 프레임에 여러 칸 떨어지는 것을 방지할 수 있음 for row in (0..<rows-1).reversed() { for column in 0..<columns { if grid[row][column] == 1 { // 아래 칸이 비어있는지 확인 if grid[row+1][column] == 0 { // 모래를 아래로 이동 grid[row][column] = 0 grid[row+1][column] = 1 } // 아래칸이 막혀 있다면 아래 대각선 방향으로 이동 시도 else if column > 0 && grid[row+1][column-1] == 0 { // 왼쪽 아래 대각선으로 이동 grid[row][column] = 0 grid[row+1][column-1] = 1 } else if column < columns-1 && grid[row+1][column+1] == 0 { // 오른쪽 아래 대각선으로 이동 grid[row][column] = 0 grid[row+1][column+1] = 1 } } } } } }
이번 포스트에서는 간단한 모래 시뮬레이터를 만들어보면서 Celluar automata가 어떤식으로 동작을 하는지 알아보았습니다.
1. 시뮬레이션을 할 공간인 Grid를 먼저 정의하고 나서
2. 각 Grid의 상태 값을 정의하고 이를 관리하는 배열을 생성한 다음에
3. 매 프레임마다 모든 cell에 대해 규칙에 따라 값을 업데이트 합니다. (이때 규칙은 현재 셀의 상태 값 + 셀의 주변부로 부터 영향을 받음)
4. 그리고 새로 업데이트된 상태값을 다시 화면에 그려줍니다.
아주 단순한 규칙을 반복해서 따르는 형태의 시뮬레이터이지만 상태 값의 갯수, 규칙의 가짓 수를 추가하면 다양한 현상을 시뮬레이션 할 수 있습니다. 그리고 이를 현실의 물리 법칙을 모델링하는데도 활용할 수 있습니다.
GitHub - OneMoreThink/SimpleSandSimulator
Contribute to OneMoreThink/SimpleSandSimulator development by creating an account on GitHub.
github.com
'Apple🍎 > Metal' 카테고리의 다른 글
Metal 렌더링 파이프라인 (0) 2025.03.29 CPU와 GPU 비교와 GPGPU 프로그래밍의 이해 (0) 2025.03.27 cellular automata(세포 자동자)는 뭘까? (0) 2025.03.23 Metal 둘러보기 (0) 2025.03.21 셰이더(shader)는 무엇인가? (0) 2025.03.20