- Metal(2)-셰이더 코드 작성까지2025년 04월 02일
- 2료일
- 작성자
- 2025.04.02.오후11:54
이전 글에서 메탈이란 GPU에 접근하여 빠른 그래픽 처리를 가능하게 해주는 저수준 API라는 것을 학습했다. 즉 Spiritekit, Animation 밑의 있는 것!! 이번엔 그래서 메탈이 어떻게 렌더링을 하는지 살펴볼 계획입니다.
렌더링 프로세스
Metal은 '초기화 단계'와 '렌더 패스 단계'로 나뉜다.
초기화 단계(앱 수명주기와 함께하는 객체들)
MTLDevice
let device = MTLCreateSystemDefaultDevice()
모든 Metal 객체 생성의 시작점. 물리적 GPU를 추상화한 인터페이스라고 보면 된다.
MTLCommandQueue
let commandQueue = device.makeCommandQueue()
GPU에 보낼 명령들을 순서대로 관리하는 대기열.
실제로 여러 프레임의 렌더링 명령이 동시에 큐에 들어갈 수 있으며 이 큐를 통해 CPU와 GPU가 효율적으로 병렬작업한다.
CAMetalLayer
let metalLayer = CAMetalLayer() metalLayer.device = device metalLayer.pixelFormat = .bgra8Unorm metalLayer.framebufferOnly = true metalLayer.frame = view.layer.frame view.layer.addSublayer(metalLayer)
화면에 Metal 컨텐츠 표시하기 위한 layer. GPU가 그린 이미지를 실제 보여주는 역할.
- pixelFormat: 텍스처의 픽셀 형식 지정
- framebufferOnly: 최적화를 위해 true로 설정(읽기 전용으로 설정)
- frame: 화면에 표시될 영역 설정
MTLLibrary 및 MTLFunction
let library = device.makeDefaultLibrary() let vertexFunction = library?.makeFunction(name: "vertexPassThrough") let fragmentFunction = library?.makeFunction(name: "fragmentPassThrough")
MTLibrary는 GPU에서 실행될 셰이더 코드의 컬렉션입니다.
MTLFunction은 라이브러리에서 특정 셰이더 함수를 나타냅니다.
MTLRenderPipelineState
let pipelineDescriptor = MTLRenderPipelineDescriptor() pipelineDescriptor.vertexFunction = vertexFunction pipelineDescriptor.fragmentFunction = fragmentFunction pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm let pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineDescriptor)
MTLRenderPipelineState는 그래픽 렌더링 과정 전체를 정의합니다.
위에서 만든 MTLFunction들을 여기서 연결한다고 보면된다.
colorAttachments: 결과가 출력될 텍스처의 속성을 설정
렌더링 중에는 수정할 수 없으므로 모든 설정을 미리 완료해야한다.
MTLBuffer
let vertexData: [Float] = [ 0, 1, // 상단 중앙 점 (x, y) -1, -1, // 좌측 하단 점 (x, y) 1, -1 // 우측 하단 점 (x, y) ] let vertexDataSize = vertexData.count * MemoryLayout<Float>.size let vertexBuffer = device.makeBuffer(bytes: vertexData, length: vertexDataSize, options: [])
CPU와 GPU 간에 공유되는 메모리 영역, 셰이더에 전달할 데이터를 저장한다.
렌더 패스 단계(매프레임마다 생성되는 객체들)
Drawable
let drawable = metalLayer.nextDrawable()
CAMetalDrawable은 화면에 표시할 준비가 된 텍스처를 제공한다.
매 프레임마다 새로운 Drawable을 요청해야한다.
한 프레임이 화면에 표시되는 동안 다음 프레임을 준비할 수 있게한다.
MTLRenderPassDescriptor
let renderPassDescriptor = MTLRenderPassDescriptor() renderPassDescriptor.colorAttachments[0].texture = drawable.texture renderPassDescriptor.colorAttachments[0].loadAction = .clear renderPassDescriptor.colorAttachments[0].clearColor = .init(red: 0, green: 0, blue: 0, alpha: 1)
렌더링 작업에 대한 세부사항 정의한다.
texture: 그림을 그릴 텍스처 지정
loadAction: 렌더링 시작 전 텍스처 처리 방법
clearColor: 텍스처를 지울때 사용할 색상
MTLCommandBuffer 및 MTLRenderCommandEncoder
let commandBuffer = commandQueue.makeCommandBuffer() let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) commandEncoder?.setRenderPipelineState(pipelineState) commandEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0) commandEncoder?.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexData.count/2) commandEncoder?.endEncoding()
GPU에 보낼 명령들을 모아두는 버퍼.
MTLRenderCommandEncoder는 명령을 GPU가 이해할 수 있는 형식으로 인코딩.
- setRenderPipelineState: 사용할 파이프라인 설정
- setVertexBuffer: 사용할 버텍스 버퍼 설정 (index: 0은 셰이더에서 buffer(0)으로 접근)
- drawPrimitives: 실제 그리기 명령
- .triangle: 3개 버텍스를 연결하여 삼각형 그리기
- .triangleStrip: 연속된 삼각형 그리기 (인접 삼각형이 변을 공유)
- .line: 선 그리기
- .point: 점 그리기
Drawable 등록 및 CommandBuffer 커밋
commandBuffer.present(drawable) commandBuffer.commit()
마지막으로, 그려진 결과를 화면에 표시하도록 등록하고 명령을 커밋합니다.
- present: 그려진 결과를 화면에 표시할 준비
- commit: 모든 명령을 GPU에 전송하여 실행
자 여기까지가 어떻게 렌더링 되어지는 과정이다. 내가 원하는건 사진에 필터를 입히는거다. CIFilter같은 완성형 API말고 나만의!
이를 위해서는 CIKernel이란놈을 공부해야한다.
Metal을 이용한 커스텀 이미지 필터
1. 필터함수 생성
func applySepiaToneFilter(image: UIImage) -> UIImage { // 필터 적용 코드 작성 // 이 함수는 이미지를 입력받아 필터를 적용한 이미지를 반환합니다 return filteredImage }
2. Metal 컴퓨트 셰이더 작성
kernel void sepiaToneFilter(texture2d<float, access::write> outTexture [[texture(0)]], const device float4 *inPixel [[buffer(0)]], uint2 gid [[thread_position_in_grid]]) { float4 pixel = inPixel[gid.x + gid.y * outTexture.get_width()]; float3 sepia = float3(0.393, 0.769, 0.189); float3 intensity = float3(0.299, 0.587, 0.114); float3 result = pixel.rgb * intensity; result += sepia * (1.0 - intensity); outTexture.write(float4(result, pixel.a), gid); }
이 셰이더는 입력 이미지의 픽셀을 가져와 세피아 톤 필터를 적용. 세피아 필터는 픽셀의 RGB값을 강도 값으로 곱한 뒤 세피아 톤 값에 강도의 역을 곱해 더하는 방식.
3. Metal 파이프라인 생성
이제 컴퓨터 셰이더르 가지고 있으니 Metal 파이프라인을 생성해야한다.
func createMetalPipeline(shaderCode: String, image: UIImage) -> UIImage { // Metal 파이프라인 생성하는 코드 // 1. Metal 디바이스 생성 let device = MTLCreateSystemDefaultDevice()! // 2. 커맨드 큐 생성 let commandQueue = device.makeCommandQueue()! // 3. 라이브러리 생성 (컴파일된 셰이더 코드 포함) let library = try! device.makeLibrary(source: shaderCode, options: nil) // 4. 컴퓨트 파이프라인 상태 생성 let pipelineState = try! device.makeComputePipelineState(function: library.makeFunction(name: "sepiaToneFilter")!) // 5. 커맨드 버퍼 생성 let commandBuffer = commandQueue.makeCommandBuffer()! // 셰이더 실행 및 필터 적용 로직... return filteredImage }
4. applyFilter method 생성
func applyFilter(_ image: UIImage, filtertype: Filter, intensity: Float) -> UIImage { //1. uiimage -> CGImage -> MTLTexture guard let cgImage = image.cgImage else { return image } let textureLoader = MTKTextureLoader(device: device) guard let inTexture = try? textureLoader.newTexture(cgImage: cgImage, options: nil) else { return image } //2. 출력텍스처생성 let descriptor = MTLTextureDescriptor.texture2DDescriptor( pixelFormat: inTexture.pixelFormat, width: inTexture.width, height: inTexture.height, mipmapped: false ) descriptor.usage = [.shaderRead, .shaderWrite] guard let outTexture = device.makeTexture(descriptor: descriptor) else { return image } //3. 셰이더파이프라인 설정 let functionName = filtertype.metalFunction if pipeLineStateCache[functionName] == nil { guard let function = defaultLibrary.makeFunction(name: functionName), let pipelineState = try? device.makeComputePipelineState(function: function) else { return image } pipeLineStateCache[functionName] = pipelineState } guard let pipelineState = pipeLineStateCache[functionName] else { return image } //4.명령인코더설정 guard let commandBuffer = commandQueue.makeCommandBuffer(), let encoder = commandBuffer.makeComputeCommandEncoder() else { return image } encoder.setComputePipelineState(pipelineState) encoder.setTexture(inTexture, index: 0) encoder.setTexture(outTexture, index: 1) var intensityValue = intensity encoder.setBytes(&intensityValue, length: MemoryLayout<Float>.size, index: 0) //5.그리드및스레드설정 let threadGroupSize = MTLSize(width: 16, height: 16, depth: 1) let threadGroupCount = MTLSize( width: (inTexture.width + threadGroupSize.width - 1) / threadGroupSize.width, height: (inTexture.height + threadGroupSize.height - 1) / threadGroupSize.height, depth: 1 ) encoder.dispatchThreadgroups(threadGroupCount, threadsPerThreadgroup: threadGroupSize) encoder.endEncoding() // 6. 커맨드 실행 및 결과 반환 commandBuffer.commit() commandBuffer.waitUntilCompleted() // 7. MTLTexture -> UIImage 변환 let ciImage = CIImage(mtlTexture: outTexture)! let context = CIContext() guard let cgImageOut = context.createCGImage(ciImage, from: ciImage.extent) else { return image } return UIImage(cgImage: cgImageOut)
순서대로 정리해보자.
- Metal 셰이더는 CPU메모리의 UIImage를 사용할수없다.GPU에서 접근 가능한 MTLTexture로 변환
MTKTextureLoader는 CGImage를 MTLTexture로 변환하는 유틸리티. newTexture를 통해 CGImage를 GPU memory에 업로드해 MTLTexture를 생성. - 출력 텍스처 생성
셰이더가 필터링 결과를 저장할 빈 텍스처 생성한다.
- texture2DDescriptor: 2D 텍스처의 속성을 정의합니다
pixelFormat: 입력 텍스처와 동일한 픽셀 형식을 사용해 호환성 유지
mipmapped: false 밉맵을 사용하지않음. (2D 이미지에 불필요) - 셰이더 파이프라인 생성
Metal 세이더를 실행할 파이프라인 상태를 설정
성능 최적화를 위해 파이프라인의 상태를 캐싱한다. 없을때만 .metal파일에서 셰이더 함수를 로드하여
makeComputePipelineState에서 실행 가능한 상태로 컴파일한다 - 커맨드 버퍼 및 인코더 설정
: 셰이더 실행을 위한 명령을 준비.
- makeCommandBuffer(): GPU에 보낼 명령 버퍼를 생성하고 컴퓨트 명령 인코드를 생성한다.
파이프라인 상태, 입력/출력텍스처를 생성한다. 그 후 필터파라미터(Intensity)를 셰이더에 전달
setTexture메서드의 index값은 셰이더 코드에서 작성했던 [[texture[0]]], [[texture[1]]]을 의미한다
setBytes메서드의 Index값은 셰이더 코드에서 [[buffer(0)]]과 일치해야한다. - 그리드 및 스레드 설정
:셰이더를 병렬로 실행할 스레드 분배를 설정한다.
ThreadGroupSize: 한 스레드 그룹당 실행할 스레드수(16*16)
ThreadGroupCount: 이미지 크기를 스레드 그룹크기로 나눠 총 그룹스를 계산.
스레드 그룹 크기는 GPU 성능에 영향을 미치므로 일반적으로 (8*8 or 16*16)을 사용한다고 한다.
그 후 쓰레드 그룹을 GPU에 배포한다. 이로써 이미지의 각픽셀을 처리할 스레드를 효율적으로 분배할수있다. - 커맨드 실행
GPU에서 셰이더를 실행하고 완료를 기다린다.
commit: 명령을 GPU 제출. waitUntilCompleted: GPU작업이 끝날때까지 대기(동기 처리) - 결과변환
:필터링된 MTLTexture를 UIImage로변환
이슈
: 이미지 필터를 적용하는데는 성공했다. 하지만 원본과 달리 뒤집혀서 나오는 현상이 발생했다.
원인: 좌표계 차이
- MTLTexture는 기본적으로 좌상단이(0,0). y축은 아래로 증가.
- UIImage는 좌상단이 (0,0). Y축이 아래로 증가. 하지만 UIImage자체는 이 좌표계를 직접 사용하지 않고, 내부적으로 CGImage와 방향정보를 조합해 표시한다.
- CoreGraphis 좌표계는 왼쪽하단이(0,0). 즉 이미지가 위에서 아래로 렌더링
- UIImage -> CGImage: UIImage의 cgImage속성을 호출하면 CGImage로 반환되면서 픽셀데이터는 좌측 하단 원점으로 저장된다.
- CGImage->MTLTexture: 메탈 좌표계에 맞춰 데이터가 로드된다. 이때 상하반전이 일어난다!!!
- 셰이더 처리: 셰이더는 MTLTexture를 읽고 출력 텍스처에 기록. 입력 출력 모두 메탈 좌표계를 따른다.
- MTLTexture->CIImage->CGImage->UIImage : 최종 UIimage생성시 orientation을 명시하지 않으면 .Up이 디폴트.
해결방법
1. orientation을 바꾸면 되겟네?
return UIImage(cgImage: cgImageOut,scale: 1.0,orientation: .downMirrored)
요런식으로 뒤집으면 해결이 된다.
2. 셰이더자체에서 보정한다.
uint2 correctedGid = uint2(gid.x, inTexture.get_height() - 1 - gid.y);
inTexture의 Y축을 반전시켜 메탈 좌표계에서 CGImage의 원본방향에 맞게 읽는다. 하단이 (0,0)
출력은 메탈 좌표계에 그대로 맞춰 기록되어 이후 CIImage변환에서 반전되어 원본과 일치하다.
이 두개의 방법은 모두 장단점이 있다.
1번의 경우 CPU에서 orientation을 보정하는 것이다.
하지만 2번의 경우 셰이더에서 보정을 하기에 GPU를 활용하는 것이다.
CPU는 메탈 작업을 설정하고 데이터를 준비하는데 집중해야한다. 1번으로 하면 고해상도 이미지(4k)처리할때 CPU가 방향정보를 매번 계산하거나 메모리를 조정하는 부담이 있다.
2번으로하면 GPU는 이미 픽셀당 수십개의 연산을 작업중이므로 성능에 거의영향을 주지않는다.
결국 성능도 좋고 Metal에 적합하다. 자 그러면 이번엔 MSL로 나만의 필터를 하나 만들어보자.
나만의 필터(네온 글로우)
: 이미지에 네온 효과를 적용하여 윤곽선을 강조하고 시각적으로 화려한 효과를 만들어보려한다.
1. 소벨 엣지 검출(Sobel Edge Detection)
와 이건ㄷ ㅐ학교에서 수업들었던건데 기억이...새롭록
이미지의 각 픽셀에서 밝기의 기울기(gradient)를 계산하여 엣지를 검출한다.
float sobelEdge(texture2d<float, access::read> inTexture, uint2 gid) { float3x3 sobelX = float3x3( -1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0 ); float3x3 sobelY = float3x3( -1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0 ); float gx = 0.0, gy = 0.0; for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { uint2 offset = uint2(x,y); float4 color = inTexture.read(gid + offset); float luminace = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); gx += luminace * sobelX[y+1][x+1]; gy += luminace * sobelY[y+1][x+1]; } } return sqrt(gx * gx + gy * gy); }
먼저 3*3소벨 커널을 만들어야한다.SobelX는 X축 방향의 기울기를 감지하는 3X3커널이고 Y도 Y축
RGB->그레이스케일로 변환. 이는 인간의 눈은 색상에 따라 다른 민감도를 가지기에 가중치를 적용했다.
기울기를 계산허가 위해 gx,gy에 더하는 여기서 3x3주변픽셀에 소벨커널을 적용하는것.
마지막으로 기울기 크기를 계산한다. 기울기 벡터의 크기를 계산하여 엣지의 강도를 구하는것이 목적이므로 피타고라스 정리
2.네온글로우MSL
kernel void neon_glow(texture2d<float, access::read> inTexture [[texture(0)]], texture2d<float, access::write> outTexture [[texture(1)]], uint2 gid [[thread_position_in_grid]], constant float &intensity [[buffer(0)]]) { if (gid.x >= inTexture.get_width() || gid.y >= inTexture.get_height()) { return ;} uint2 correctedGid = uint2(gid.x, inTexture.get_height() - 1 - gid.y); float4 color = inTexture.read(correctedGid); float edgeStrength = sobelEdge(inTexture, correctedGid); edgeStrength *= 3.0 * intensity; float3 neonBase = color.rgb + float3(0.3,0.15,0.45) * intensity; float3 neon = neonBase + edgeStrength * intensity * float3(0.7,0.6,0.8); float luminance = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); color.rgb = mix(color.rgb, neon, intensity); // a * (1-t) + b* t color.rgb = mix(float3(luminance), color.rgb, 1.0 + intensity * 0.5); // 채도 증가 outTexture.write(clamp(color, 0.0, 1.0), gid); }
소벨 엣지 검출을 사용하여 엣지 강도를 구하고 우리가 스크롤을 통해 관리할 intensity 매개변수와 3을 더 곱해서 극대화한다.
힙한 색을 위해 보라색을 intensity에 비례해서 추가해주었다.
그리고 neon = neon이쪽에서 엣지 부분에만 강한 보라/분홍계열의 색을 추가해주었다. 더 엣지에 포인트 주기위함!
luminance는 휘도를 의미한다. 휘도? 색상의 밝기만을 나타내는 그레이스케일 저 소수점 4자리의 근거는 Rec.709에 따르면 저렇게 하라한다.
mix = a * (1-t) + b* t 선형 보간을 의미한다. 자 그러면 첫번째 mix를 보자.
result = original * (1-intensity) + neon * intensity.
그 후 휘도(채도가 없다)만 있는 색상을 혼합하여 채도를 증가시키기 위해
luminance * (-intensity*0.5) + color.rgb * (1.0+intensity*0.5) 를 해주었다.
=> 원본색상에서 그레이스케일 성분을 일부 빼고 원본 색상 성분을 증폭해주었다.
채도 증가의 원리
채도(Saturation)는 색상이 얼마나 순수한지, 또는 회색과 얼마나 다른지를 나타낸다. 채도를 증가시키는 수학적 방법 중 하나는:
- 색상에서 그레이스케일 성분을 빼고
- 그 차이를 증폭시킨 다음
- 원래 휘도(밝기)를 유지하도록 조정하는 것입니다
이 코드에서는 위의 복잡한 과정을 단순화하여, mix 함수와 1보다 큰 혼합 비율을 사용해 비슷한 효과를 얻고 있당
저기서 intensity가 1이라고 하자. 그러면 결과값은 -0.5luminance + color.rgb * 1.5 : 원본색상의 채도를 50% 증가시킨다.
CIKernel
:Custom Core Image Filter를 만드는데 사용되는 GPU기반 이미지 처리 루틴
kernerl Lanaguage routine의 리턴타입은 vec4(Core Image Kernel Language) or float4(Metal shading Language)
https://developer.apple.com/documentation/coreimage/cikernel
CIKernel | Apple Developer Documentation
A GPU-based image-processing routine used to create custom Core Image filters.
developer.apple.com
요건 공식문서 . 오버뷰를 보면
사용자 지정 필터가 색상과 지오메트리 정보를 모두 사용하지만 동시에 처리할 필요가 없는 경우 이미지 처리 코드를 분리하여 성능을 향상시킬 수 있습니다. 색상 처리 단계에는 CIColorKernel 객체를 사용하고 지오메트리 처리 단계에는 CIWarpKernel 객체를 사용합니다.
CIColorKernel: 픽셀 색상 값만 변경하는 필터에 사용. 이미지의 기하학적 구조는 변경하지 않는다.
- 색상 보정, 색조 조정, 블러효과
CIWrapKernel: 이미지의 기하학적 구조를 변형하는 필터. 픽셀 위치를 재배치
- 왜곡, 변형, 뒤틀림 효과
CIKernel: 일반적인 목적의 커널. 두개다 모두 수행 ㄱㄴ
일반적인 필터 커널 특징
- 반환 타입은 vec4(Core Image Kernel Language) 또는 float4(Metal Shading Language)입니다. 이는 출력 이미지의 픽셀 색상을 반환합니다.
- 0개 이상의 입력 이미지를 사용할 수 있으며, 각 입력 이미지는 sampler 타입의 매개변수로 표현됩니다.
커널 루틴의 작동방식1. 소스이미지 좌표 계산(destCoord와 samplerTransform함수사용)2. 소스이미지에서 샘플링(sample 함수 사용)3. 최종 픽셀 색상 계산(return 키워드 출력)
#include <CoreImage/CoreImage.h> extern "C" { namespace coreimage { float4 do_nothing(sampler src) { return src.sample(src.coord()); } } }
MSL로 작성했다. 이 코드는 입력 이미지를 변경 없이 그대로 통과시키는 간단한 필터 구현. src.sample(src.coord())는 현재 좌표의 픽셀 값을 그대로 가져온다.
그런데 저기서 extern "C"는 뭘까?
안에 정의된 함수 혹은 헤더파일에 관해서는 맹글링하지 말라는 뜻이다. 흠.. 그러면 맹글링이 뭔데? 댕글링포인터는 아는데 ㅎ
C++에서 맹글링은 컴파일러가 함수나 변수의 이름을 변환하는 과정입니다, 그러면 왜 이런게 있을까?
1. 오버로딩: C++은 같은 이름의 함수를 매개변수만 다르게 정의할수 있다.
2. 클래스범위: 다른클래스에 같은 이름의 함수가 있을 수 있다.
컴파일러는 이런함수들을 구별하기위해 내부적으로 함수이름을 맹글링(변환)한다. 하지만 extern "C"는 C++ 코드 안에서 이부분을 C방식으로 처리하라는 듯. 즉 저기 안에있는것은 맹글링 없이 그대로 그 이름대로 컴파일된다. 저 위의 예시코드에서는 함수이름은 do_nothing으로 유지되고 네임스페이스로 인해 외부에서는 coreimage::do_nothing으로 정의
'면접준비' 카테고리의 다른 글
근본으로돌아가자(7)-String,Array으로 시작해서 Sequence까지 (0) 2025.04.04 Metal(1)- 메탈을 알기전에 필요한 것들 (1) 2025.03.29 Autolayout 모든 것: 사이클부터 제약조건까지 (0) 2025.03.26 Apple의 보안 (0) 2025.03.15 근본으로 돌아가자(6) Image (2) 2025.03.05 다음글이전글이전 글이 없습니다.댓글