ABOUT ME

-

  • 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

     

     

    댓글

Designed by Tistory.