ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Particle Simulator] cpu 연산 - gpu 랜더링 방법
    Apple🍎/Metal 2025. 3. 30. 22:47

    1. Metal Setup 과정 

    1. GPU 자원 초기화 : 시스템의 GPU에 접근하기 위한 기본 자원을 설정, MTLCreateSystemDefaultDevice( )를 호출하여 기기의 GPU를 가리키는 참조를 얻습니다. 
    2. 화면 표시 영역 준비 : GPU가 렌더링한 내용을 표시할 특수한 뷰(MTKView)를 생성, 해당 뷰는 앱의 기존 UI 계층 구조 내에 배치되어, GPU 렌더링 결과가 나타날 화면 영역을 정의
    3. 렌더링 루프 설정 : 대리자 설정을 통해 렌더링 루프를 구성합니다. 이를 통해 화면이 새로고침될때마다 대리자 클래스의 draw( ) 메서드가 자동으로 호출하여 연속적인 프레임 렌더링이 가능해집니다. 
    4. 렌더링 옵션 구성: framebufferOnly = false 와 같은 설정으로 렌더링 방식을 조정합니다. 이 경우, 렌더링된 화면을 다시 읽고 처리할 수 있게 됩니다. 또한 화면이 그려지기 전 지워질 배경색도 설정합니다. 
    5. 명령 전달 채널 생성 : commandQueue를 생성하여 GPU에 작업을 전송할 통로를 마련합니다. 해당 대기열을 통해 "이것을 계산해라", "이것을 계산해라"등의 명령이 GPU로 전달됩니다. 
    6. 기하학적 데이터 준비 : 전체 화면을 덮는 사각형(두 개의 삼각형으로 구성)을 위한 정점 데이터를 메모리에 준비합니다. 이 사각형은 셰이더 효과나 텍스처를 표시하기 위한 캔버스 역할을 합니다.
    7. 데이터 저장소 설정 : GPU 메모리에 텍스처를 생성하여 입자의 색상 데이터나 기타 정보를 저장할 공간을 마련합니다. 텍스처는 이미지 데이터를 효율적으로 저장하고 접근할 수 있는 특별한 구조입니다.
    8. 렌더링 파이프라인 설정 : setupRenderPipeline() 메서드를 호출하여 정점 및 프래그먼트 셰이더를 포함한 전체 렌더링 파이프라인을 구성합니다. 이것은 GPU가 데이터를 어떻게 처리하여 최종 이미지를 만들지 정의합니다.
    private func setupMetal() {
        // Get the default Metal device (GPU)
        device = MTLCreateSystemDefaultDevice()
        
        // Create and configure a Metal view
        metalView = MTKView(frame: view.bounds, device: device)
        metalView.delegate = self  // This view will call draw() each frame
        metalView.framebufferOnly = false  // Allows texture access
        metalView.clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
        
        // Create the command queue for sending work to the GPU
        commandQueue = device.makeCommandQueue()
        
        // Create vertex buffer for a full-screen quad (2 triangles)
        // Format: [x, y, z, texCoordX, texCoordY] for 4 vertices
        let vertexData: [Float] = [...]
        vertexBuffer = device.makeBuffer(bytes: vertexData, length: ...)
        
        // Create a texture to hold particle colors
        let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(...)
        texture = device.makeTexture(descriptor: textureDescriptor)!
        
        // Set up the render pipeline
        setupRenderPipeline()
    }

     

    metalView 대리자 작동 방식 

    • 프레임 생성 주기: 디스플레이는 일반적으로 초당 60회(60Hz) 또는 120회(120Hz) 등의 속도로 화면을 갱신합니다. 각 갱신마다 새로운 이미지(프레임)가 필요합니다.
    • 누가 렌더링 시점을 결정하는가?: MTKView는 디스플레이의 갱신 주기에 맞춰 언제 새 프레임을 그려야 할지 알고 있습니다. 하지만 무엇을 그려야 할지는 모릅니다.
    • 렌더링 내용의 분리: 무엇을 그릴지에 대한 실제 로직은 다른 클래스(보통 ViewController나 Renderer 클래스)에 있습니다.

    구체적인 작동 과정

    1. metalView는 디스플레이 동기화를 관리합니다. 화면이 갱신될 준비가 되면 (대략 16.7ms마다, 60Hz 디스플레이 기준), metalView는 이제 그릴 때가 되었다고 판단합니다.
    2. 그릴 시간이 되면, metalView는 자신의 대리자(delegate)에게 있는 draw(in:) 메서드를 호출합니다.
    3. 호출받은 메서드는 실제 렌더링 코드를 실행합니다.

    이 패턴이 작동하려면 self 클래스(대리자로 지정된 클래스)는 MTKViewDelegate 프로토콜을 구현해야 합니다.

    class YourViewController: UIViewController, MTKViewDelegate {
        // MTKViewDelegate 프로토콜 필수 메서드
        func draw(in view: MTKView) {
            // 실제 렌더링 코드
        }
        
        func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
            // 화면 크기 변경시 호출됨
        }
    }
    

     

    MTKView의 framebufferOnly 속성 

    Metal에서 프레임버퍼는 화면에 표시되는 최종 이미지를 저장하는 메모리 영역입니다. 기본적으로 framebufferOnly는 true로 설정되어 있는데, 이것은 성능 최적화를 위한 것입니다. 이 경우 GPU는 프레임버퍼에 그리기만 할 수 있고, 그 내용을 다시 읽어들일 수는 없습니다.

     

    framebufferOnly = false로 설정하면, GPU가 프레임버퍼의 내용을 텍스처처럼 읽을 수 있게 됩니다. 이것은 다음과 같은 작업을 가능하게 합니다.

    1. 렌더링 결과를 다시 읽어 후처리 효과(blur, bloom 등)를 적용할 수 있습니다.
    2. 이전 프레임의 결과를 현재 프레임의 입력으로 사용할 수 있습니다.
    3. 픽셀 데이터를 계산에 활용할 수 있습니다.

    예를 들어, 입자 시스템에서는 각 입자의 위치와 속도를 텍스처에 저장하고, 이를 다음 프레임에서 읽어 입자를 업데이트할 수 있습니다. 이미지 처리에서는 필터나 변환을 순차적으로 적용할 때 이 기능이 필요합니다. 다만, 이 설정을 false로 바꾸면 성능이 약간 저하될 수 있습니다. Metal이 특정 최적화를 적용할 수 없게 되기 때문입니다.

     

    화면을 덮는 사각형 만들기 (Create vertex buffer for a full-screen quad)

    GPU 렌더링에서는 대부분의 이미지 처리 효과가 "프래그먼트 셰이더"라는 프로그램을 통해 구현됩니다. 이 셰이더는 화면의 각 픽셀에 대해 실행됩니다. 전체 화면 효과를 적용하려면, 모든 픽셀에 접근해야 합니다.

    가장 효율적인 방법은 화면 전체를 덮는 사각형(두 개의 삼각형으로 구성)을 그리는 것입니다. 이 사각형은 단순히 캔버스 역할을 하며, 여기에 텍스처를 매핑하거나 셰이더 효과를 적용할 수 있습니다.

    이 방식은 다음과 같은 용도로 사용됩니다:

    • 포스트 프로세싱 효과(블러, 색상 보정, HDR 등)
    • 2D 입자 시뮬레이션
    • 이미지 필터링
    • 전체 화면 전환 효과

    GPU는 이러한 삼각형을 빠르게 처리하도록 최적화되어 있어, 모든 픽셀을 직접 조작하는 것보다 훨씬 효율적입니다.

     

    TextureDescriptor의 역할 : 텍스쳐(이미지 데이터 메모리)의 속성을 설정 

    textureDescriptor는 텍스처의 속성을 정의하는 설계도와 같습니다. 텍스처는 GPU에서 이미지 데이터를 저장하고 처리하는 특수한 메모리 구조입니다. 텍스처 디스크립터는 다음과 같은 중요한 속성들을 설정합니다:

    1. 크기(width, height): 텍스처의 픽셀 차원
    2. 픽셀 형식(pixelFormat): 각 픽셀의 데이터 구조(RGBA8Unorm, RGBA16Float 등)
    3. 사용 목적(usage): 텍스처가 어떻게 사용될지(렌더링 대상, 셰이더 읽기 등)
    4. 저장 모드(storageMode): 텍스처 데이터가 어디에 저장될지(GPU 메모리, CPU와 공유 메모리 등)
    5. 샘플링 설정: 텍스처를 읽을 때 사용할 필터링 방법

    코드에서 texture2DDescriptor 메소드는 2D 텍스처를 위한 디스크립터를 생성합니다. 이 디스크립터를 사용하여 실제 텍스처 객체를 만들고, 이 텍스처에 입자 시스템의 색상 데이터나 기타 정보를 저장합니다.

    입자 시스템에서는 이 텍스처가 각 입자의 위치, 속도, 색상 등을 저장하는 데 사용될 수 있습니다. 셰이더가 이 텍스처를 읽어 입자를 시각화하거나, 다음 프레임의 입자 상태를 계산하는 데 활용합니다.

    private func setupRenderPipeline() {
        // Get the shader code from the default library
        let library = device.makeDefaultLibrary()
        
        // Get references to our vertex and fragment shader functions
        let vertexFunction = library?.makeFunction(name: "vertexShader")
        let fragmentFunction = library?.makeFunction(name: "fragmentShader")
        
        // Configure the rendering pipeline
        let pipelineDescriptor = MTLRenderPipelineDescriptor()
        pipelineDescriptor.vertexFunction = vertexFunction
        pipelineDescriptor.fragmentFunction = fragmentFunction
        pipelineDescriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
        
        // Create the pipeline state object
        pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
    }

     

    셰이더 라이브러리 로드 (device.makeDefaultLibrary( ) )

    • 셰이더 라이브러리: 이것은 GPU에서 실행되는 특수 프로그램(셰이더)들을 모아둔 컬렉션입니다.
    • makeDefaultLibrary(): 이 메서드는 앱 번들에 컴파일된 채 포함된 기본 Metal 셰이더 코드를 로드합니다.
    • 실제로 일어나는 일: 앱을 빌드할 때, .metal 파일들이 컴파일되어 앱 번들에 포함됩니다. 이 코드는 그 컴파일된 셰이더들에 접근합니다.

    특정 셰이더 함수 참조 휙득 (library?.makeFunction(name: _") )

    • vertexShader: 정점 데이터를 처리하는 셰이더입니다. 3D 좌표를 화면 좌표로 변환하고, 정점 간 데이터를 준비합니다.
    • fragmentShader: 픽셀의 최종 색상을 계산하는 셰이더입니다. 텍스처 적용, 조명, 특수 효과 등을 담당합니다.
    • makeFunction(name:): 라이브러리에서 특정 이름을 가진 셰이더 함수를 찾아 참조를 반환합니다.

     

    렌더링 파이프 라인 구성 ( MTLRenderPipelineDescriptor( ) )

    MTLRenderPipelineDescriptor: 이것은 GPU가 어떻게 데이터를 처리하고 렌더링할지 정의하는 설정들의 모음입니다.

    • vertexFunction 할당: 정점 처리 단계에 어떤 코드를 사용할지 지정합니다.
    • fragmentFunction 할당: 픽셀 색상 계산 단계에 어떤 코드를 사용할지 지정합니다.
    • 렌더링 결과의 픽셀 데이터 형식을 설정합니다.

    2. 데이터 구조 Setup 과정

    Cpu에서 연산될 입자들의 데이터 구조를 정의합니다.

    private func setupBuffers() {
        // Create a grid of particles (initially all empty)
        particles = [Particle](repeating: Particle(isEmpty: true, hasBeenUpdated: false), 
                              count: gridWidth * gridHeight)
        
        // Create a matching grid of colors (initially all transparent)
        colorBuffer = [Color](repeating: Color(r: 0, g: 0, b: 0, a: 0), 
                             count: gridWidth * gridHeight)
    }

     

    3. 메인 렌더링 루프 설정하기 

    draw(in view: MTKView) 함수는 Metal 그래픽스 시스템에서 매 프레임마다 화면을 그리는 핵심 함수입니다. 이 함수는 입자 시뮬레이션(모래 시뮬레이션)을 업데이트하고 화면에 렌더링하는 전체 과정을 담당합니다.

    func draw(in view: MTKView) {
        // Increment frame counter
        frameCount += 1
        
        // Create new particles periodically (every 20 frames)
        if frameCount % 20 == 0 {
            createParticlesAtCenter()
        }
        
        // Update physics simulation
        updateSandSimulation()
        
        // Update the texture with current particle colors
        updateTextureFromColorBuffer()
        
        // Render the particles to screen
        guard let drawable = view.currentDrawable,
              let renderPassDescriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer() else {
            return
        }
        
        // Create render encoder and draw
        guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
            return
        }
        
        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        renderEncoder.setFragmentTexture(texture, index: 0)
        
        // Draw a quad (using triangle strip with 4 vertices)
        renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
        
        renderEncoder.endEncoding()
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }

    프레임 카운터 사용 (frameCount)

    이 라인은 렌더링 루프가 실행된 횟수를 추적합니다. 프레임 카운터는 시간을 측정하는 단순한 방법으로, 애니메이션이나 주기적 이벤트를 제어하는 데 사용됩니다. 기본적으로 프레임은 초당 60번(60Hz 디스플레이) 또는 120번(120Hz 디스플레이) 실행되므로, 이 카운터는 대략적인 시간 측정의 역할을 합니다.

     

    입자 추가 및 시뮬레이션 수행

    cpu에서 particles와 colorBuffer에 대하여 새로운 모래 입자를 생성하여 추가하고 현재 존재하는 모래 입자에 대해 물리 시뮬레이션을 적용하여 각 입자의 상태값을 변경합니다. 

        // 새로운 모래 입자 추가하기 
        private func createParticlesAtCenter() {
            let centerX = gridWidth / 2
            let centerY = gridHeight / 4  // 화면 상단 부분에 생성
            
            // 중앙에 하나의 입자만 생성
            let index = centerY * gridWidth + centerX
            
            // 이미 입자가 있는지 확인
            if particles[index].isEmpty {
                // 입자 생성
                particles[index] = Particle(isEmpty: false, hasBeenUpdated: false)
                
                // 모래 색상 설정
                colorBuffer[index] = Color(r: 220, g: 180, b: 80, a: 255)
            }
        }
        
        // 모래 입자 업데이트 (물리 시뮬레이션)
        private func updateSandSimulation() {
            // 아래에서 위로 순회 (중력 방향을 고려)
            for y in (0..<gridHeight).reversed() {
                for x in 0..<gridWidth {
                    let index = y * gridWidth + x
                    
                    // 빈 공간이거나 이미 업데이트된 입자는 건너뛰기
                    if particles[index].isEmpty || particles[index].hasBeenUpdated {
                        continue
                    }
                    
                    // 모래 입자 업데이트 로직
                    let belowY = y + 1
                    
                    // 화면 바닥에 도달했는지 확인
                    if belowY >= gridHeight {
                        particles[index].hasBeenUpdated = true
                        continue
                    }
                    
                    // 1. 바로 아래가 비어있으면 아래로 떨어짐
                    if particles[belowY * gridWidth + x].isEmpty {
                        moveParticle(fromIndex: index, toIndex: belowY * gridWidth + x)
                    }
                    // 2. 왼쪽 아래가 비어있으면 왼쪽 아래로 이동
                    else if x > 0 && particles[belowY * gridWidth + (x-1)].isEmpty {
                        moveParticle(fromIndex: index, toIndex: belowY * gridWidth + (x-1))
                    }
                    // 3. 오른쪽 아래가 비어있으면 오른쪽 아래로 이동
                    else if x < gridWidth-1 && particles[belowY * gridWidth + (x+1)].isEmpty {
                        moveParticle(fromIndex: index, toIndex: belowY * gridWidth + (x+1))
                    }
                    // 4. 이동할 수 없으면 해당 위치에 정지
                    else {
                        particles[index].hasBeenUpdated = true
                    }
                }
            }
            
            // 다음 프레임을 위해 업데이트 플래그 초기화
            for i in 0..<particles.count {
                particles[i].hasBeenUpdated = false
            }
        }
        
        // 입자 이동 헬퍼 함수
        private func moveParticle(fromIndex: Int, toIndex: Int) {
            // 1. 물리 데이터 이동
            particles[toIndex] = particles[fromIndex]
            particles[toIndex].hasBeenUpdated = true
            particles[fromIndex] = Particle(isEmpty: true, hasBeenUpdated: false)
            
            // 2. 색상 데이터 이동
            colorBuffer[toIndex] = colorBuffer[fromIndex]
            colorBuffer[fromIndex] = Color(r: 0, g: 0, b: 0, a: 0)
        }

     

    텍스쳐 업데이트 

    시뮬레이션의 현재 상태를 GPU 텍스처로 전송합니다.

        private func updateTextureFromColorBuffer() {
            texture.replace(
                region: MTLRegionMake2D(0, 0, gridWidth, gridHeight),
                mipmapLevel: 0,
                withBytes: colorBuffer,
                bytesPerRow: gridWidth * MemoryLayout<Color>.stride
            )
        }

    입자 시뮬레이션(모래 시뮬레이션)은 두 가지 주요 데이터를 다룹니다.

    1. 위치 및 물리 데이터: 입자의 위치, 속도, 가속도, 충돌 상태 등
    2. 시각적 데이터: 입자가 화면에서 어떻게 보일지 결정하는 색상 정보

    물리 시뮬레이션 부분(updateSandSimulation())은 CPU에서 실행되며, 모든 입자의 위치와 상태를 업데이트합니다. 이 계산 결과는 내부 메모리(CPU 메모리)에 저장됩니다. 그러나 이 모든 위치 데이터를 GPU에 전송할 필요는 없습니다.

    렌더링을 위해 GPU가 실제로 필요로 하는 것은 "각 위치에 어떤 색상이 있는가?"라는 정보입니다. 이것이 바로 colorBuffer입니다. 이 버퍼는 격자의 각 셀(입자가 있는 위치)에 대한 색상 정보를 담고 있습니다. texture.replace() 함수는 CPU 메모리에 있는 colorBuffer의 데이터를 GPU 메모리(텍스처)로 복사합니다. 이 텍스처는 나중에 프래그먼트 셰이더에서 읽어 화면에 색상을 표시하는 데 사용됩니다.

     

    렌더링 준비 및 검증

    guard let drawable = view.currentDrawable,
          let renderPassDescriptor = view.currentRenderPassDescriptor,
          let commandBuffer = commandQueue.makeCommandBuffer() else {
        return
    }

    이 코드는 실제 렌더링에 필요한 중요 객체들을 준비합니다.

    • drawable: 최종 렌더링 결과가 표시될 화면의 현재 드로어블(표시 가능한) 영역입니다. 이것은 실제로 화면에 픽셀을 그릴 수 있는 표면입니다.
    • renderPassDescriptor: 렌더링 패스의 설정을 포함합니다. 화면을 어떤 색상으로 지울지, 깊이/스텐실 테스트를 어떻게 처리할지 등을 정의합니다.
    • commandBuffer: GPU에 전송할 명령들을 담는 버퍼입니다. 이것은 "GPU에게 전달할 작업 목록"이라고 생각할 수 있습니다.

    렌더링 명령 인코더 생성

    guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else {
        return
    }

    렌더 인코더는 실제 렌더링 명령을 기록하는 객체입니다.

    • makeRenderCommandEncoder: 렌더링 패스 설정을 사용해 새 인코더를 생성합니다.
    • 이 인코더는 렌더링 명령들을 순차적으로 기록하여 나중에 GPU가 처리할 수 있게 합니다.

    렌더링 상태 및 자원 설정

    renderEncoder.setRenderPipelineState(pipelineState)
    renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
    renderEncoder.setFragmentTexture(texture, index: 0)

    이 코드는 GPU에게 렌더링에 필요한 자원과 설정을 전달합니다.

    • setRenderPipelineState: 앞서 설정한 렌더링 파이프라인(셰이더와 렌더링 설정)을 사용하도록 지시합니다.
    • setVertexBuffer: 정점 데이터(전체 화면 사각형의 위치와 텍스처 좌표)를 GPU에게 제공합니다.
    • setFragmentTexture: 프래그먼트 셰이더에서 사용할 텍스처(입자 색상 정보)를 설정합니다.

    렌더링 완료 및 제출

    renderEncoder.endEncoding()
    commandBuffer.present(drawable)
    commandBuffer.commit()

    마지막으로, 렌더링 작업을 마무리하고 GPU에 제출합니다.

    • endEncoding(): 렌더 인코더에 더 이상 명령을 추가하지 않겠다고 알립니다.
    • present(drawable): 렌더링이 완료되면 결과를 화면에 표시하라고 지시합니다.
    • commit(): 모든 명령을 포함한 명령 버퍼를 GPU에 제출하여 실행을 시작합니다.
     

     

    GitHub - OneMoreThink/Metal

    Contribute to OneMoreThink/Metal development by creating an account on GitHub.

    github.com

    댓글

Designed by Tistory.