본문 바로가기

프로그래밍/iOS,macOS

[Metal] AR 얼굴인식 및 obj 렌더링

AR 환경에서 얼굴의 anchor 에 obj 파일을 렌더링 하는 내용을 다룬다.
다른 환경으로의 전달을 위해 별도의 렌더타겟을 만들고, CVPixelBuffer로 복사하는 방식 사용하며, 실제 뷰 렌더링은 다루지 않는다.

공유타입

#include <simd/simd.h>

typedef struct {
  matrix_float4x4 projectionMatrix;
  matrix_float4x4 viewMatrix;
  matrix_float4x4 modelMatrix;
  matrix_float3x3 normalMatrix;
  vector_float3 ambientLightColor;
  vector_float3 directionalLightDirection;
  vector_float3 directionalLightColor;
  float materialShininess;
} SharedUniforms;

 

기본멤버

let semaphore = DispatchSemaphore(value: 1)
var device: MTLDevice!
var session: ARSession!
var commandQueue: MTLCommandQueue!

// 렌더타겟
var renderTarget: MTLTexture!
var renderPassDescriptor: MTLRenderPassDescriptor!
var textureCache: CVMetalTextureCache!

// 오브젝트 관련
var objectPipelineState: MTLRenderPipelineState?
var objectMeshes:[MTKMesh] = []
var objectTextures:[(MTLTexture,MTLTexture)] = []
var objectDepthState:MTLDepthStencilState!


// 기본 퐁 쉐이더
var phongVertexFunction: MTLFunction!
var phongFragmentFunction: MTLFunction!


// 렌더타겟의 사이즈는 1024x1024로 설정
var viewportSize: CGSize = CGSize(width: kTextureWidth , height: kTextureHeight)

 

AR 관련 기본 설정

// RendererDelegate 는 출력용 CVPixelBuffer를 전달하기 위한 용도도 별도 선언
init(delegate:RendererDelegate) {
  super.init()
  self.device = MTLCreateSystemDefaultDevice()
  self.session = ARSession()
  self.session.delegate = self
  self.delegate = delegate
     .
     .
}

// ARSession 시작 및 중지
func start() {
  print("[Renderer.start]")
  let configuration = ARFaceTrackingConfiguration()
  self.session.run(configuration)
}

func stop() {
  print("[Renderer.stop]")
  self.session.pause()
}





// ARSessionDelegate
extension Renderer: ARSessionDelegate {
  func session(_ session: ARSession, didUpdate frame: ARFrame) {
    let cmTime = CMTime(seconds:frame.timestamp, preferredTimescale: 1_000_000)
    self.update(time:  cmTime)
  }

  func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
    guard let faceAnchor = anchors.first as? ARFaceAnchor else { return }
    print("[ARSessionDelegate] didAdd face anchor")
  }

  func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {

  }
}

 

메탈 초기화

// library
guard let defaultLibrary = try? self.device.makeDefaultLibrary(bundle: Bundle(for: Renderer.self)) else {
  print("[Renderer.initMetal] init error")
  return
}

// 쉐이더함수
phongVertexFunction = defaultLibrary.makeFunction(name: "phong_vertex")
phongFragmentFunction = defaultLibrary.makeFunction(name: "phong_fragment")


// command queue
commandQueue = device.makeCommandQueue()


// 공유데이터 버퍼 : 모델, 뷰, 프로젝션, 라이트 등 쉐이더에서 공통적으로 사용될 구조체를 위한 버퍼
let sharedBufferSize = (MemoryLayout<SharedUniforms>.size & ~0xFF) + 0x100
sharedBuffer = self.device.makeBuffer(length: sharedBufferSize, options: .storageModeShared)
sharedBuffer.label = "SharedBuffer"

 

렌더타겟 설정

텍스처와 depth 텍스처를 생성하고, 렌더패스에 해당 텍스처들을 설정한다. 렌더타겟 테스처와 출력용 픽셀버퍼의 픽셀포맷은 .bgra8Unorm(텍스처), kCVPixelFormatType_32BGRA(픽셀버퍼)로 동일하게 구성해야 한다.
깊이 버퍼는 보통 obj 파일을 불러들이면 그 순서가 일정하지 않기에 정상적인 렌더링을 위해 추가해 주어야 한다.

// 렌더타겟 텍스처
let textureDescriptor = MTLTextureDescriptor()
textureDescriptor.textureType = MTLTextureType.type2D
textureDescriptor.width = kTextureWidth
textureDescriptor.height = kTextureHeight
textureDescriptor.pixelFormat = .bgra8Unorm
textureDescriptor.storageMode = .shared
textureDescriptor.usage = [ .renderTarget, .shaderRead]
self.renderTarget = self.device.makeTexture(descriptor: textureDescriptor)
        
// depth 텍스처
let depthTextureDescriptor =
        MTLTextureDescriptor.texture2DDescriptor(
          pixelFormat: .depth32Float,
          width: kTextureWidth,
          height: kTextureHeight,
          mipmapped: false)
depthTextureDescriptor.usage = [.renderTarget]
let depthTexture = self.device.makeTexture(descriptor: depthTextureDescriptor)

// 렌더타겟에 렌더링 이후 해당 데이터를 저장할 픽셀버퍼 : 텍스처 포맷에 맞게 픽셀버퍼 생성
CVPixelBufferCreate(kCFAllocatorDefault,
                    textureDescriptor.width,
                    textureDescriptor.height,
                    kCVPixelFormatType_32BGRA,
                    nil,
                    &self.pixelBuffer)

// 텍스처에 렌더링을위한 렌더패스 설정
let clearColor = MTLClearColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = self.renderTarget
renderPassDescriptor.colorAttachments[0].clearColor = clearColor
renderPassDescriptor.colorAttachments[0].storeAction = .store
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.depthAttachment.texture = depthTexture
renderPassDescriptor.depthAttachment.clearDepth = 1.0

 

오브젝트 설정

오브젝트 버텍스 설정

버텍스 쉐이더에 전달한 정보를 설정한다. 아래 model io 에서 읽어들일 값들을 기준으로 설정하는데, 일반적으로 4개의 값들을 사용하게 된다.

// metal vertex descriptor
let objectVertexDescriptor = MTLVertexDescriptor()
objectVertexDescriptor.attributes[0].format = .float3
objectVertexDescriptor.attributes[0].offset = 0
objectVertexDescriptor.attributes[0].bufferIndex = 0
objectVertexDescriptor.attributes[1].format = .float3
objectVertexDescriptor.attributes[1].offset = 12
objectVertexDescriptor.attributes[1].bufferIndex = 0
objectVertexDescriptor.attributes[2].format = .float3
objectVertexDescriptor.attributes[2].offset = 24
objectVertexDescriptor.attributes[2].bufferIndex = 0
objectVertexDescriptor.attributes[3].format = .float2
objectVertexDescriptor.attributes[3].offset = 36
objectVertexDescriptor.attributes[3].bufferIndex = 0
objectVertexDescriptor.layouts[0].stride = 44
objectVertexDescriptor.layouts[0].stepRate = 1
objectVertexDescriptor.layouts[0].stepFunction = .perVertex
        
// mdl vertex descriptor
// model i/o 를 사용해 오브젝트를 읽어올때 사용한다.
let vertexDescriptor = MTKModelIOVertexDescriptorFromMetal(objectVertexDescriptor)
(vertexDescriptor.attributes[0] as! MDLVertexAttribute).name = MDLVertexAttributePosition
(vertexDescriptor.attributes[1] as! MDLVertexAttribute).name = MDLVertexAttributeNormal
(vertexDescriptor.attributes[2] as! MDLVertexAttribute).name = MDLVertexAttributeTangent
(vertexDescriptor.attributes[3] as! MDLVertexAttribute).name = MDLVertexAttributeTextureCoordinate

        

// 오브젝트 링더링을 위한 렌더파이프라인 설정
let objectPipelineDescriptor = MTLRenderPipelineDescriptor()
objectPipelineDescriptor.label = "ObjectRenderPipeline"
objectPipelineDescriptor.sampleCount = 1
objectPipelineDescriptor.vertexFunction = phongVertexFunction
objectPipelineDescriptor.fragmentFunction = phongFragmentFunction
objectPipelineDescriptor.vertexDescriptor = objectVertexDescriptor
objectPipelineDescriptor.colorAttachments[0].pixelFormat = renderTarget.pixelFormat
objectPipelineDescriptor.depthAttachmentPixelFormat = .depth32Float

do {
  try self.objectPipelineState = self.device.makeRenderPipelineState(descriptor: objectPipelineDescriptor)
} catch let error {
  print("error=\(error.localizedDescription)")
}


// depth state
let depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .lessEqual
depthDescriptor.isDepthWriteEnabled = true
self.objectDepthState = self.device.makeDepthStencilState(descriptor: depthDescriptor)

 

모델 읽기

보통은 node 객체를 하나 만들어 메시와 매터리얼 설정들을 관리하게 되는데, 아래는 읽어 오는 플로우를 보이기 위해 간단한 형태로 구성되어있다.
우선 MDLAsset() 으로 로드하게 되면 child objects로 MDLMesh를 가져오는데, 이때 필요한 정보를 요청하면 model i/o가 해당 정보를 채워 주게 된다. 보통은 obj에 normal 이 포함되어 있는 경우엔 해당 값을 가져오게 되고, 없는 경우 버텍스 값을 기반으로 normal 과 tangent를 생성해 준다. 
노말맵 적용을 위해 필요한 부분이므로, addOrthTanBasis() 메쏘드로 해당 값들을 추가해준다. 

MTKMesh.newMeshes() 메쏘드는 mdl mesh에서 mtk mesh 를 생성해준다.
MDLMesh는 여러개의 MDLSubmesh를 가지는데, 이 submesh에는 각각의 매터리얼 정보를 가지고 있다.
mesh는 obj 파일정보, submesh는 mtl 파일의 정보라고 생각하면 된다.

매터리얼 프로퍼티에는 MDLMaterialSemantic 이라는 enum 타입으로 텍스처의 종류가 정해져 있다.
해당 텍스처 정보가 있는지 확인해서 texture loader 를 통해 읽어들인다.

일단 기본색상과 노말맵을 읽어 배열에 저장해 두었다.

let textureLoader = MTKTextureLoader(device: device)
let allocator = MTKMeshBufferAllocator( device: device )

// 리소스의 obj 읽기, 폴더내의 test.obj 파일을 MDLAsset() 으로 로드한다.
let url = Bundle(for: Renderer.self).url(forResource: "test", withExtension: "obj" )
let asset = MDLAsset(url: url, vertexDescriptor: nil, bufferAllocator: allocator)

// 텍스처 로드
asset.loadTextures()


// 각 오브젝트별로 MDLVertexDescriptor 설정
for object in asset.childObjects(of: MDLMesh.self) as! [MDLMesh] {
  object.addOrthTanBasis( 
      forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate,
      normalAttributeNamed: MDLVertexAttributeNormal,
      tangentAttributeNamed: MDLVertexAttributeTangent)
  object.vertexDescriptor = vertexDescriptor
}

// 메시 변환
// MDLMesh -> MTKMesh
// return : modelIOMeshes:[MDLMesh], metalKitMeshes: [MTKMesh]
if let meshes = try? MTKMesh.newMeshes(asset: asset, device: device) {
  for (mdl,mtl) in zip( meshes.modelIOMeshes, meshes.metalKitMeshes) {
    for sourceSubmesh in mdl.submeshes as! [MDLSubmesh] {
      // sourceSubmesh.material
      if let material = sourceSubmesh.material {
        if let colorTexture = material.property(with: .baseColor)?.textureSamplerValue?.texture,
          let normalTexture = material.property(with: .tangentSpaceNormal)?.textureSamplerValue?.texture {

          let texture1 = try? textureLoader.newTexture(texture: colorTexture, 
                                options: [MTKTextureLoader.Option.generateMipmaps:true])
          
          let texture2 = try? textureLoader.newTexture(texture: normalTexture, 
                                options: [MTKTextureLoader.Option.generateMipmaps:true])

          if texture1 != nil && texture2 != nil {
            objectTextures.append((texture1!, texture2!))
          }
      	}
      }
    }

    objectMeshes.append(mtl)
  }
}

 

업데이트

let _ = semaphore.wait(timeout: DispatchTime.distantFuture)

// ARSession 에서 현재 프레임 정보를 가져온다.
guard let currentFrame = self.session.currentFrame else {
  print("[Renderer.update] frame is nil")
  return
}

// command buffer 생성
guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { return }
commandBuffer.label = "RenderCommand"


// 명령이 완료되면 처리할 내용
commandBuffer.addCompletedHandler {
  [weak self] buffer in

  if self?.renderTarget == nil {
    print("[Renderer.update] render target is nil")
  }


  // commandBuffer가 commit 되면 렌더링된 내용을 픽셀버퍼로 이동
  if let texture = self?.renderTarget, let pixelBuffer = self?.pixelBuffer {

    CVPixelBufferLockBaseAddress(pixelBuffer, [])
    let pixelBufferBytes = CVPixelBufferGetBaseAddress(pixelBuffer)!
    let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
    let region = MTLRegionMake2D(0, 0, texture.width, texture.height)

    texture.getBytes(pixelBufferBytes, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)
    CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
    self?.delegate?.renderer(self?.pixelBuffer, time: time)
  } else {
    print("[Renderer.update] render texture error")
  }

  textures.removeAll()
  self?.semaphore.signal()

}


// 이곳에 업데이트 및 렌더링 루틴 구현
// 버퍼 업데이트
// 렌더링

renderEncoder.endEncoding()
commandBuffer.commit()

 

공유 데이터 업데이트

일반적인 3d 모델을 렌더링하는 것과 다른점이 카메라의 움직임이 단말의 움직임이라는 점이다. ar 환경에 맞춰 변환 행렬들을 생성해 준다.
obj 파일의 경우 뒤집혀 있거나 사이즈가 큰 경우 모델행렬에 회전, 스케일을 추가해 준다.

guard let sharedBuffer = self.sharedBuffer else { return }
guard let anchor = frame.anchors.first as? ARFaceAnchor else { return }

let uniforms = sharedBuffer.contents().assumingMemoryBound(to: SharedUniforms.self)
uniforms.pointee.viewMatrix =  frame.camera.viewMatrix(for: .portrait)
uniforms.pointee.projectionMatrix = frame.camera.projectionMatrix(for: .portrait,
                                         viewportSize: viewportSize,
                                         zNear: 0.1,
                                         zFar: 100)


let rotateM = simd_float4x4( simd_quaternion(3.1415, simd_float3(1, 0, 0)))
var scaleM = matrix_identity_float4x4
scaleM.columns.0.x = 0.1
scaleM.columns.1.y = 0.1
scaleM.columns.2.z = 0.1

uniforms.pointee.modelMatrix = anchor.transform * rotateM * scaleM

 

노말도 모델행렬에 맞춰 줘야 하는데, 모델행렬의 전치-역행렬이 노말의 모델행렬이 된다.

// normal 의 모델변환을 위한 행렬 생성( M.transpose.inverse )
let m = uniforms.pointee.modelMatrix
let ul  = float3x3( SIMD3<Float>(m.columns.0.x, m.columns.0.y , m.columns.0.z),
SIMD3<Float>(m.columns.1.x, m.columns.1.y , m.columns.1.z),
SIMD3<Float>(m.columns.2.x, m.columns.2.y , m.columns.2.z))
uniforms.pointee.normalMatrix = ul.transpose.inverse

 

라이트 관련 설정

// 실제 환경의 조명을 참고로 화면의 빛을 조절하는 경우에 아래 값을 사용한다.
var ambientIntensity: Float = 1.0
if let lightEstimate = frame.lightEstimate {
  //ambientIntensity = Float(lightEstimate.ambientIntensity) / 1000.0
}

// ambient 설정
let ambientLightColor: vector_float3 = vector3(0.1, 0.1, 0.1)
uniforms.pointee.ambientLightColor = ambientLightColor * ambientIntensity

// 라이트 위치
var directionalLightDirection : vector_float3 = vector3(1.0, 0.0, 0.0)
directionalLightDirection = simd_normalize(directionalLightDirection)
uniforms.pointee.directionalLightDirection = directionalLightDirection

// 라이트 색상
let directionalLightColor: vector_float3 = vector3(1, 1, 1)
uniforms.pointee.directionalLightColor = directionalLightColor * ambientIntensity

// 퐁쉐이딩의 정반사 계산시 제곱할 값
uniforms.pointee.materialShininess = 30

 

 

렌더링

// 텍스처에 렌더링
guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(
                             descriptor: renderPassDescriptor) else {

  return
}

renderEncoder.label = "RenderEncoder"
renderEncoder.setCullMode(.front)

if let objectPipelineState = self.objectPipelineState {
  renderEncoder.setRenderPipelineState(objectPipelineState)
  renderEncoder.setDepthStencilState(self.objectDepthState)

  for mesh in  self.objectMeshes {
    for (index, buffer) in mesh.vertexBuffers.enumerated() {
    	renderEncoder.setVertexBuffer(buffer.buffer, offset: buffer.offset, index: index)
    }

    renderEncoder.setVertexBuffer(self.sharedBuffer, offset: 0, index: 2)
    renderEncoder.setFragmentBuffer(self.sharedBuffer, offset: 0, index: 2)


    for (index, submesh) in mesh.submeshes.enumerated() {
      let textures = self.objectTextures[index]
      renderEncoder.setFragmentTexture(textures.0, index: 0)
      renderEncoder.setFragmentTexture(textures.1, index: 1)


      renderEncoder.drawIndexedPrimitives(
                         type: submesh.primitiveType,
                         indexCount: submesh.indexCount,
                         indexType: submesh.indexType,
                         indexBuffer: submesh.indexBuffer.buffer,
                         indexBufferOffset: submesh.indexBuffer.offset)


    }
  }
}

 

 

쉐이더

#define SRGB_ALPHA 0.055

float linear_from_srgb(float x) {
  if (x <= 0.04045)
    return x / 12.92;
  else
    return powr((x + SRGB_ALPHA) / (1.0 + SRGB_ALPHA), 2.4);
}

float3 linear_from_srgb(float3 rgb) {
  return float3(linear_from_srgb(rgb.r), linear_from_srgb(rgb.g), linear_from_srgb(rgb.b));
}



constant float4 materialSpecularColor = float4(1.0, 1.0, 1.0, 1.0);

typedef struct {
    float3 position [[attribute(0)]];
    float3 normal [[attribute(1)]];
    float3 tangent [[attribute(2)]];
    float2 texCoord [[attribute(3)]];
} ObjectVertex;

struct ColorInOut {
    float4 position [[position]];
    float3 normal;
    float3 tangent;
    float3 bitangent;
    float2 texCoord;
    float3 eye_direction_cameraspace;
    float3 light_direction_cameraspace;
};

버텍스쉐이더

vertex ColorInOut phong_vertex( ObjectVertex in [[stage_in]],
                               constant SharedUniforms& uniforms [[ buffer(2) ]],
                               unsigned int vid [[ vertex_id ]])
{
    ColorInOut out;

    float4x4 model_matrix = uniforms.modelMatrix;
    float4x4 view_matrix = uniforms.viewMatrix;
    float4x4 projection_matrix = uniforms.projectionMatrix;
    float4x4 mvp_matrix = projection_matrix * view_matrix * model_matrix;
    
    // 버텍스 mvp 변환
    float4 vertex_position_modelspace = float4(in.position, 1.0f );
    out.position = mvp_matrix * vertex_position_modelspace;

    // 각 노말값을 모델 변환
    out.normal = normalize(uniforms.normalMatrix * in.normal);
    out.tangent = normalize(uniforms.normalMatrix * in.tangent);
    out.tangent = normalize( out.tangent - dot(out.tangent, out.normal) * out.normal); // 그람-슈미트 직교화
    out.bitangent =  cross(in.normal, in.tangent);
    
    
    float3 vertex_position_cameraspace = ( view_matrix * model_matrix * vertex_position_modelspace ).xyz;
    out.eye_direction_cameraspace = float3(0.0f,0.0f,0.0f) - vertex_position_cameraspace;

    // 라이트의 위치
    float3 light_position_cameraspace = ( float4(uniforms.directionalLightDirection,1.0f)).xyz;
    out.light_direction_cameraspace = light_position_cameraspace; // + out.eye_direction_cameraspace;

    out.texCoord = in.texCoord;
    return out;
}

 

프래그먼트 쉐이더

sRGB 의 옅은 스펙트럼을 넓혀주고, 노말맵을 적용하기 위한 TBN 행렬을 생성한다.
일단 샘플로 얻어온 픽셀 데이터는 0~1 사이의 값이므로, 실제 사용할 -1~1 사이의 값으로 변경시키고, 월드 변환된 노말값들로 행렬을 생성한다. 이름과 같이 Tangent, Bitangent, Normal 순서.
TBN 행렬과 노말샘플값을 곱하면 실제 픽셀의 normal 을 얻을 수 있게 된다.
이후 퐁쉐이딩의 기본 적인 처리를 수행해 정반사, 난반사에 따른 픽셀 수치를 구한다.

fragment half4 phong_fragment(ColorInOut in [[stage_in]],
                              constant SharedUniforms& uniforms [[ buffer(2) ]],
                              texture2d<float> texture [[texture(0)]],
                              texture2d<float> textureNormal [[texture(1)]])
{
    constexpr sampler colorSampler;
    
    // sRGB 보간
    float4 materialColor = texture.sample(colorSampler, in.texCoord);
    float4 baseColor = float4(linear_from_srgb(materialColor.rgb), 1.0);
    
    // normal
    float3 normalMap = textureNormal.sample(colorSampler, in.texCoord).rgb * 2.0 - 1.0;
    float3x3 TBN(in.tangent, in.bitangent, in.normal);
    float3 normal = normalize(TBN*normalMap);
    
    half4 color;
    float4 ambient_color = float4(uniforms.ambientLightColor.xyz, 1.0);
    float4 light_color = float4(uniforms.directionalLightColor.xyz, 1.0);
    
    float3 n = normal;
    float3 l = normalize(in.light_direction_cameraspace);
    float n_dot_l = saturate( dot(n, l) );

    float4 diffuse_color = light_color * n_dot_l * baseColor;

    float3 e = normalize(in.eye_direction_cameraspace);
    float3 r = -l + 2.0f * n_dot_l * n;
    float e_dot_r =  saturate( dot(e, r) );
    
    
    float4 specular_color = materialSpecularColor * light_color * pow(e_dot_r, uniforms.materialShininess);

    color = half4(ambient_color + diffuse_color + specular_color);
    
    return color;
};