-
[Particle Simulator] cpu 연산 - gpu 랜더링 방법Apple🍎/Metal 2025. 3. 30. 22:47
1. Metal Setup 과정
- GPU 자원 초기화 : 시스템의 GPU에 접근하기 위한 기본 자원을 설정, MTLCreateSystemDefaultDevice( )를 호출하여 기기의 GPU를 가리키는 참조를 얻습니다.
- 화면 표시 영역 준비 : GPU가 렌더링한 내용을 표시할 특수한 뷰(MTKView)를 생성, 해당 뷰는 앱의 기존 UI 계층 구조 내에 배치되어, GPU 렌더링 결과가 나타날 화면 영역을 정의
- 렌더링 루프 설정 : 대리자 설정을 통해 렌더링 루프를 구성합니다. 이를 통해 화면이 새로고침될때마다 대리자 클래스의 draw( ) 메서드가 자동으로 호출하여 연속적인 프레임 렌더링이 가능해집니다.
- 렌더링 옵션 구성: framebufferOnly = false 와 같은 설정으로 렌더링 방식을 조정합니다. 이 경우, 렌더링된 화면을 다시 읽고 처리할 수 있게 됩니다. 또한 화면이 그려지기 전 지워질 배경색도 설정합니다.
- 명령 전달 채널 생성 : commandQueue를 생성하여 GPU에 작업을 전송할 통로를 마련합니다. 해당 대기열을 통해 "이것을 계산해라", "이것을 계산해라"등의 명령이 GPU로 전달됩니다.
- 기하학적 데이터 준비 : 전체 화면을 덮는 사각형(두 개의 삼각형으로 구성)을 위한 정점 데이터를 메모리에 준비합니다. 이 사각형은 셰이더 효과나 텍스처를 표시하기 위한 캔버스 역할을 합니다.
- 데이터 저장소 설정 : GPU 메모리에 텍스처를 생성하여 입자의 색상 데이터나 기타 정보를 저장할 공간을 마련합니다. 텍스처는 이미지 데이터를 효율적으로 저장하고 접근할 수 있는 특별한 구조입니다.
- 렌더링 파이프라인 설정 : 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 클래스)에 있습니다.
구체적인 작동 과정
- metalView는 디스플레이 동기화를 관리합니다. 화면이 갱신될 준비가 되면 (대략 16.7ms마다, 60Hz 디스플레이 기준), metalView는 이제 그릴 때가 되었다고 판단합니다.
- 그릴 시간이 되면, metalView는 자신의 대리자(delegate)에게 있는 draw(in:) 메서드를 호출합니다.
- 호출받은 메서드는 실제 렌더링 코드를 실행합니다.
이 패턴이 작동하려면 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가 프레임버퍼의 내용을 텍스처처럼 읽을 수 있게 됩니다. 이것은 다음과 같은 작업을 가능하게 합니다.
- 렌더링 결과를 다시 읽어 후처리 효과(blur, bloom 등)를 적용할 수 있습니다.
- 이전 프레임의 결과를 현재 프레임의 입력으로 사용할 수 있습니다.
- 픽셀 데이터를 계산에 활용할 수 있습니다.
예를 들어, 입자 시스템에서는 각 입자의 위치와 속도를 텍스처에 저장하고, 이를 다음 프레임에서 읽어 입자를 업데이트할 수 있습니다. 이미지 처리에서는 필터나 변환을 순차적으로 적용할 때 이 기능이 필요합니다. 다만, 이 설정을 false로 바꾸면 성능이 약간 저하될 수 있습니다. Metal이 특정 최적화를 적용할 수 없게 되기 때문입니다.
화면을 덮는 사각형 만들기 (Create vertex buffer for a full-screen quad)
GPU 렌더링에서는 대부분의 이미지 처리 효과가 "프래그먼트 셰이더"라는 프로그램을 통해 구현됩니다. 이 셰이더는 화면의 각 픽셀에 대해 실행됩니다. 전체 화면 효과를 적용하려면, 모든 픽셀에 접근해야 합니다.
가장 효율적인 방법은 화면 전체를 덮는 사각형(두 개의 삼각형으로 구성)을 그리는 것입니다. 이 사각형은 단순히 캔버스 역할을 하며, 여기에 텍스처를 매핑하거나 셰이더 효과를 적용할 수 있습니다.
이 방식은 다음과 같은 용도로 사용됩니다:
- 포스트 프로세싱 효과(블러, 색상 보정, HDR 등)
- 2D 입자 시뮬레이션
- 이미지 필터링
- 전체 화면 전환 효과
GPU는 이러한 삼각형을 빠르게 처리하도록 최적화되어 있어, 모든 픽셀을 직접 조작하는 것보다 훨씬 효율적입니다.
TextureDescriptor의 역할 : 텍스쳐(이미지 데이터 메모리)의 속성을 설정
textureDescriptor는 텍스처의 속성을 정의하는 설계도와 같습니다. 텍스처는 GPU에서 이미지 데이터를 저장하고 처리하는 특수한 메모리 구조입니다. 텍스처 디스크립터는 다음과 같은 중요한 속성들을 설정합니다:
- 크기(width, height): 텍스처의 픽셀 차원
- 픽셀 형식(pixelFormat): 각 픽셀의 데이터 구조(RGBA8Unorm, RGBA16Float 등)
- 사용 목적(usage): 텍스처가 어떻게 사용될지(렌더링 대상, 셰이더 읽기 등)
- 저장 모드(storageMode): 텍스처 데이터가 어디에 저장될지(GPU 메모리, CPU와 공유 메모리 등)
- 샘플링 설정: 텍스처를 읽을 때 사용할 필터링 방법
코드에서 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 ) }
입자 시뮬레이션(모래 시뮬레이션)은 두 가지 주요 데이터를 다룹니다.
- 위치 및 물리 데이터: 입자의 위치, 속도, 가속도, 충돌 상태 등
- 시각적 데이터: 입자가 화면에서 어떻게 보일지 결정하는 색상 정보
물리 시뮬레이션 부분(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
'Apple🍎 > Metal' 카테고리의 다른 글
Metal-Cpp : C++ 개발자를 위한 Apple의 GPU 사용법 (0) 2025.04.03 [Particle Simulator] Compute Shader 사용해 gpu 연산하기 (0) 2025.04.01 Metal 렌더링 파이프라인 (0) 2025.03.29 CPU와 GPU 비교와 GPGPU 프로그래밍의 이해 (0) 2025.03.27 cellular automata: 간단한 모래 시뮬레이터 만들기 (0) 2025.03.24