[譯] 基於 Metal 的 ARKit 使用指南(上)

加強現實提供了一種將虛擬內容渲染到經過移動設備攝像頭捕獲的真實世界場景之上的方法。上個月,在 WWDC 2017 上,咱們都很是興奮地看到了 蘋果 的新 ARKit 高級 API 框架,它運行於搭載 A9 處理器或更高配置的 iOS 11 設備上。咱們看到的一些 ARKit 實驗已至關出色,好比下面這個:html

alt text
ARKit

一個 ARKit 應用中包含 3 種不一樣的層:前端

  • 追蹤層 - 不須要額外的配置就能夠採用視覺慣性定位追蹤場景。
  • 場景理解層 - 利用平面檢測,點擊檢測和光照估計來檢測場景屬性的能力。
  • 渲染層 - 因爲 SpriteKit 和 SceneKit 提供的模板 AR 視圖,所以能夠輕鬆集成,也可使用 Metal自定義視圖。全部的預渲染處理都是由 ARKit 完成的,它還負責使用 AVFoundation 和 CoreMotion 捕獲圖像。

在本系列的第一部分中,咱們將主要關注 Metal 下的 渲染,並在本系列的下一部分討論其餘兩個部分。在一個 AR 應用中,追蹤層場景理解層 徹底由 ARKit 框架處理,而 渲染層SpriteKitSceneKitMetal 處理:react

alt text
ARKit 1

開始以前,咱們須要經過一個 ARSessionConfiguration 對象建立一個 ARSession 實例,接着咱們在這個配置上調用 run() 方法。ARSession 同時會依賴 AVCaptureSessionCMMotionManager 運行對象來獲取追蹤的圖像和運動數據。最後,ARSession 將會輸出當前 frame 到一個 ARFrame 對象。android

alt text
ARKit 2

ARSessionConfiguration 對象包含了會話將會使用的追蹤類型信息。 ARSessionConfiguration 基礎配置類提供了 3 個自由度的運動追蹤 (設備 方向) 而其子類 ARWorldTrackingSessionConfiguration,提供了 6 個自由度的運動追蹤 (設備 位置方向)。ios

alt text
ARKit 4

當設備不支持真實場景追蹤時,它會採用基本配置:git

if ARWorldTrackingSessionConfiguration.isSupported { 
    configuration = ARWorldTrackingSessionConfiguration()
} else {
    configuration = ARSessionConfiguration() 
}
複製代碼

ARFrame 包含捕獲的圖像,跟蹤信息以及經過 ARAnchor 對象獲取的場景信息,,**ARAnchor ** 對象包含有關真實世界位置和方向的信息,而且能夠輕鬆地添加,更新或從會話中刪除。跟蹤是實時肯定物理位置的能力。 然而,世界追蹤決定了位置和方向,它與物理距離一塊兒工做,相對於起始位置並提供3D特徵點。程序員

ARFrame 的最後一個組件是 ARCamera 對象,它便於轉換(平移,旋轉,縮放),而且包含了跟蹤的狀態和相機的相關方法。跟蹤質量在很大程度上依賴於不間斷的傳感器數據,靜態場景,而且在場景紋理複雜的環境中更加準確。跟蹤狀態有三個值:不可用(攝像機只有單位矩陣),限制(場景功能不足或不夠靜態)和 正常(攝像機被填充數據)。 會話中斷是因爲相機輸入不可用或中止跟蹤形成的:github

func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { 
    if case .limited(let reason) = camera.trackingState {
        // Notify user of limited tracking state
    } 
}
func sessionWasInterrupted(_ session: ARSession) { 
    showOverlay()
}
func sessionInterruptionEnded(_ session: ARSession) { 
    hideOverlay()
    // Optionally restart experience
}
複製代碼

SceneKit 中使用 ARSCNView 的代理進行渲染,包括添加,更新或者刪除節點。相似的,SpriteKit 使用 ARSKView 的代理將SKNodes 映射爲 ARAnchor 對象。因爲 SpriteKit2D,所以它不能使用真實世界的攝像頭位置,因此它將錨點的位置投影到 ARSKView,並在投影的位置上將精靈渲染爲一個廣告牌(平面),因此精靈會一直面對着攝像頭。對於 Metal,沒有自定義的 AR 視圖,因此重任就落在了程序員手裏。爲了處理渲染的圖像,咱們須要:swift

  • 繪製背景攝像機圖像 (從像素緩衝區生成一個紋理)
  • 更新虛擬攝像頭
  • 更新光照
  • 更新幾何圖形的變換

全部這些信息都在 ARFrame 對象中。獲取 frame,有兩種方式:輪詢或使用代理。咱們將簡單介紹後者。我使用了 MetalARKit 模板,把它精簡到最小,這樣我就能更好地理解它是如何工做的。我作的第一件事是移除全部的 C 依賴,這樣就不須要橋接。它在之後會頗有用,由於類型和枚舉常量能夠在 API 代碼和着色器之間共享,但這篇文章的目的並不須要。後端

接着,回到 ViewController 上,它須要做爲 MTKViewARSession 的代理。咱們建立一個 Renderer 實例,用於同代理一塊兒實時更新應用:

var session: ARSession!
var renderer: Renderer!

override func viewDidLoad() {
    super.viewDidLoad()
    session = ARSession()
    session.delegate = self
    if let view = self.view as? MTKView {
        view.device = MTLCreateSystemDefaultDevice()
        view.delegate = self
        renderer = Renderer(session: session, metalDevice: view.device!, renderDestination: view)
        renderer.drawRectResized(size: view.bounds.size)
    }
    let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(gestureRecognize:)))
    view.addGestureRecognizer(tapGesture)
}
複製代碼

正如你所看到的,咱們還添加了一個手勢識別,用於在場景中添加虛擬內容。首先,咱們獲取會話的當前幀,接着建立一個變換將咱們的實體放到攝像頭前(本例中 0.3 米),最後使用這個變換在會話中添加一個新的錨點。

func handleTap(gestureRecognize: UITapGestureRecognizer) {
    if let currentFrame = session.currentFrame {
        var translation = matrix_identity_float4x4
        translation.columns.3.z = -0.3
        let transform = simd_mul(currentFrame.camera.transform, translation)
        let anchor = ARAnchor(transform: transform)
        session.add(anchor: anchor)
    }
}
複製代碼

咱們分別使用 viewWillAppear()viewWillDisappear() 方法啓動和暫停會話:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    let configuration = ARWorldTrackingSessionConfiguration()
    session.run(configuration)
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    session.pause()
}
複製代碼

剩下的就是咱們須要實現視圖更新、會話錯誤和中斷的代理方法:

func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
    renderer.drawRectResized(size: size)
}

func draw(in view: MTKView) {
    renderer.update()
}

func session(_ session: ARSession, didFailWithError error: Error) {}

func sessionWasInterrupted(_ session: ARSession) {}

func sessionInterruptionEnded(_ session: ARSession) {}
複製代碼

打開 Renderer.swift 文件。要注意的第一件事是使用一個很是方便的協議,它可讓咱們訪問全部的 MTKView屬性:

protocol RenderDestinationProvider {
    var currentRenderPassDescriptor: MTLRenderPassDescriptor? { get }
    var currentDrawable: CAMetalDrawable? { get }
    var colorPixelFormat: MTLPixelFormat { get set }
    var depthStencilPixelFormat: MTLPixelFormat { get set }
    var sampleCount: Int { get set }
}
複製代碼

如今咱們能夠擴展 MTKView 類(在 ViewController中),以便其遵照這個協議:

extension MTKView : RenderDestinationProvider {}
複製代碼

Renderer 類的高級視圖,如下爲僞代碼:

init() {
    setupPipeline()
    setupAssets()
}

func update() {
    updateBufferStates()
    updateSharedUniforms()
    updateAnchors()
    updateCapturedImageTextures()
    updateImagePlane()
    drawCapturedImage()
    drawAnchorGeometry()
}
複製代碼

和往常同樣,咱們首先使用 setupPipeline() 函數設置管道。 而後,在 **setupAssets()**中,咱們建立了模型,每當咱們使用咱們的單擊手勢時,模型將被加載。 MTKView 委託將調用 update() 函數獲取所需更新並繪製。 咱們詳細介紹他們。 首先咱們看看 updateBufferStates(),它更新咱們寫入當前幀的緩衝區的位置(本實例中,咱們使用一個 3 個槽的環形緩衝區):

func updateBufferStates() {
    uniformBufferIndex = (uniformBufferIndex + 1) % maxBuffersInFlight
    sharedUniformBufferOffset = alignedSharedUniformSize * uniformBufferIndex
    anchorUniformBufferOffset = alignedInstanceUniformSize * uniformBufferIndex
    sharedUniformBufferAddress = sharedUniformBuffer.contents().advanced(by: sharedUniformBufferOffset)
    anchorUniformBufferAddress = anchorUniformBuffer.contents().advanced(by: anchorUniformBufferOffset)
}
複製代碼

updateSharedUniforms() 方法中,咱們更新 frame 的共享 uniform 變量並設置場景的光照:

func updateSharedUniforms(frame: ARFrame) {
    let uniforms = sharedUniformBufferAddress.assumingMemoryBound(to: SharedUniforms.self)
    uniforms.pointee.viewMatrix = simd_inverse(frame.camera.transform)
    uniforms.pointee.projectionMatrix = frame.camera.projectionMatrix(withViewportSize: viewportSize, orientation: .landscapeRight, zNear: 0.001, zFar: 1000)
    var ambientIntensity: Float = 1.0
    if let lightEstimate = frame.lightEstimate {
        ambientIntensity = Float(lightEstimate.ambientIntensity) / 1000.0
    }
    let ambientLightColor: vector_float3 = vector3(0.5, 0.5, 0.5)
    uniforms.pointee.ambientLightColor = ambientLightColor * ambientIntensity
    var directionalLightDirection : vector_float3 = vector3(0.0, 0.0, -1.0)
    directionalLightDirection = simd_normalize(directionalLightDirection)
    uniforms.pointee.directionalLightDirection = directionalLightDirection
    let directionalLightColor: vector_float3 = vector3(0.6, 0.6, 0.6)
    uniforms.pointee.directionalLightColor = directionalLightColor * ambientIntensity
    uniforms.pointee.materialShininess = 30
}
複製代碼

updateAnchors() 方法中,咱們用當前 frame 的錨點的變換來更新錨定元素緩衝區:

func updateAnchors(frame: ARFrame) {
    anchorInstanceCount = min(frame.anchors.count, maxAnchorInstanceCount)
    var anchorOffset: Int = 0
    if anchorInstanceCount == maxAnchorInstanceCount {
        anchorOffset = max(frame.anchors.count - maxAnchorInstanceCount, 0)
    }
    for index in 0..<anchorInstanceCount {
        let anchor = frame.anchors[index + anchorOffset]
        var coordinateSpaceTransform = matrix_identity_float4x4
        coordinateSpaceTransform.columns.2.z = -1.0
        let modelMatrix = simd_mul(anchor.transform, coordinateSpaceTransform)
        let anchorUniforms = anchorUniformBufferAddress.assumingMemoryBound(to: InstanceUniforms.self).advanced(by: index)
        anchorUniforms.pointee.modelMatrix = modelMatrix
    }
}
複製代碼

updateCapturedImageTextures() 方法中,咱們從提供的幀捕獲的圖像中建立兩個紋理:

func updateCapturedImageTextures(frame: ARFrame) {
    let pixelBuffer = frame.capturedImage
    if (CVPixelBufferGetPlaneCount(pixelBuffer) < 2) { return }
    capturedImageTextureY = createTexture(fromPixelBuffer: pixelBuffer, pixelFormat:.r8Unorm, planeIndex:0)!
    capturedImageTextureCbCr = createTexture(fromPixelBuffer: pixelBuffer, pixelFormat:.rg8Unorm, planeIndex:1)!
}
複製代碼

updateImagePlane() 方法中,咱們更新圖像屏幕的紋理座標,讓它可以保持比例並填滿整個視圖:

func updateImagePlane(frame: ARFrame) {
    let displayToCameraTransform = frame.displayTransform(withViewportSize: viewportSize, orientation: .landscapeRight).inverted()
    let vertexData = imagePlaneVertexBuffer.contents().assumingMemoryBound(to: Float.self)
    for index in 0...3 {
        let textureCoordIndex = 4 * index + 2
        let textureCoord = CGPoint(x: CGFloat(planeVertexData[textureCoordIndex]), y: CGFloat(planeVertexData[textureCoordIndex + 1]))
        let transformedCoord = textureCoord.applying(displayToCameraTransform)
        vertexData[textureCoordIndex] = Float(transformedCoord.x)
        vertexData[textureCoordIndex + 1] = Float(transformedCoord.y)
    }
}
複製代碼

drawCapturedImage() 方法中,咱們在場景中繪製攝像頭:

func drawCapturedImage(renderEncoder: MTLRenderCommandEncoder) {
    guard capturedImageTextureY != nil && capturedImageTextureCbCr != nil else { return }
    renderEncoder.pushDebugGroup("DrawCapturedImage")
    renderEncoder.setCullMode(.none)
    renderEncoder.setRenderPipelineState(capturedImagePipelineState)
    renderEncoder.setDepthStencilState(capturedImageDepthState)
    renderEncoder.setVertexBuffer(imagePlaneVertexBuffer, offset: 0, index: 0)
    renderEncoder.setFragmentTexture(capturedImageTextureY, index: 1)
    renderEncoder.setFragmentTexture(capturedImageTextureCbCr, index: 2)
    renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
    renderEncoder.popDebugGroup()
}
複製代碼

最後,在 drawAnchorGeometry() 中爲咱們建立的虛擬內容繪製錨點:

func drawAnchorGeometry(renderEncoder: MTLRenderCommandEncoder) {
    guard anchorInstanceCount > 0 else { return }
    renderEncoder.pushDebugGroup("DrawAnchors")
    renderEncoder.setCullMode(.back)
    renderEncoder.setRenderPipelineState(anchorPipelineState)
    renderEncoder.setDepthStencilState(anchorDepthState)
    renderEncoder.setVertexBuffer(anchorUniformBuffer, offset: anchorUniformBufferOffset, index: 2)
    renderEncoder.setVertexBuffer(sharedUniformBuffer, offset: sharedUniformBufferOffset, index: 3)
    renderEncoder.setFragmentBuffer(sharedUniformBuffer, offset: sharedUniformBufferOffset, index: 3)
    for bufferIndex in 0..<mesh.vertexBuffers.count {
        let vertexBuffer = mesh.vertexBuffers[bufferIndex]
        renderEncoder.setVertexBuffer(vertexBuffer.buffer, offset: vertexBuffer.offset, index:bufferIndex)
    }
    for submesh in mesh.submeshes {
        renderEncoder.drawIndexedPrimitives(type: submesh.primitiveType, indexCount: submesh.indexCount, indexType: submesh.indexType, indexBuffer: submesh.indexBuffer.buffer, indexBufferOffset: submesh.indexBuffer.offset, instanceCount: anchorInstanceCount)
    }
    renderEncoder.popDebugGroup()
}
複製代碼

回到咱們前面簡要提到的 setupPipeline() 方法。咱們建立兩個渲染管道狀態的對象,一個用於捕獲的圖像(攝像頭) ,另外一個用於在場景中放置虛擬對象時建立的錨點。正如預期的那樣,每一個狀態對象都有本身的一對頂點和片斷函數 - 它把咱們帶到咱們須要查看的最後一個文件 - Shaders.metal 文件。在第一對被捕獲圖像的着色部分,在頂點着色器中,咱們傳入圖像的頂點位置和紋理座標參數:

vertex ImageColorInOut capturedImageVertexTransform(ImageVertex in [[stage_in]]) {
    ImageColorInOut out;
    out.position = float4(in.position, 0.0, 1.0);
    out.texCoord = in.texCoord;
    return out;
}
複製代碼

在片斷着色器中,咱們對兩個紋理進行採樣,獲得給定紋理座標下的顏色,而後返回轉換後的 RGB 顏色:

fragment float4 capturedImageFragmentShader(ImageColorInOut in [[stage_in]],
                                            texture2d<float, access::sample> textureY [[ texture(1) ]],
                                            texture2d<float, access::sample> textureCbCr [[ texture(2) ]]) {
    constexpr sampler colorSampler(mip_filter::linear, mag_filter::linear, min_filter::linear);
    const float4x4 ycbcrToRGBTransform = float4x4(float4(+1.0000f, +1.0000f, +1.0000f, +0.0000f),
                                                  float4(+0.0000f, -0.3441f, +1.7720f, +0.0000f),
                                                  float4(+1.4020f, -0.7141f, +0.0000f, +0.0000f),
                                                  float4(-0.7010f, +0.5291f, -0.8860f, +1.0000f));
    float4 ycbcr = float4(textureY.sample(colorSampler, in.texCoord).r, textureCbCr.sample(colorSampler, in.texCoord).rg, 1.0);
    return ycbcrToRGBTransform * ycbcr;
}
複製代碼

對於第二個幾何錨點的着色器,在頂點着色器中,咱們計算咱們頂點在剪輯空間中的位置,並輸出剪裁和光柵化,而後爲每一個面着色不一樣的顏色,而後計算觀察座標空間中頂點的位置,最後將咱們的座標系轉換到世界座標系:

vertex ColorInOut anchorGeometryVertexTransform(Vertex in [[stage_in]],
                                                constant SharedUniforms &sharedUniforms [[ buffer(3) ]],
                                                constant InstanceUniforms *instanceUniforms [[ buffer(2) ]],
                                                ushort vid [[vertex_id]],
                                                ushort iid [[instance_id]]) {
    ColorInOut out;
    float4 position = float4(in.position, 1.0);
    float4x4 modelMatrix = instanceUniforms[iid].modelMatrix;
    float4x4 modelViewMatrix = sharedUniforms.viewMatrix * modelMatrix;
    out.position = sharedUniforms.projectionMatrix * modelViewMatrix * position;
    ushort colorID = vid / 4 % 6;
    out.color = colorID == 0 ? float4(0.0, 1.0, 0.0, 1.0)  // Right face
              : colorID == 1 ? float4(1.0, 0.0, 0.0, 1.0)  // Left face
              : colorID == 2 ? float4(0.0, 0.0, 1.0, 1.0)  // Top face
              : colorID == 3 ? float4(1.0, 0.5, 0.0, 1.0)  // Bottom face
              : colorID == 4 ? float4(1.0, 1.0, 0.0, 1.0)  // Back face
              :                float4(1.0, 1.0, 1.0, 1.0); // Front face
    out.eyePosition = half3((modelViewMatrix * position).xyz);
    float4 normal = modelMatrix * float4(in.normal.x, in.normal.y, in.normal.z, 0.0f);
    out.normal = normalize(half3(normal.xyz));
    return out;
}
複製代碼

在片斷着色器中,咱們計算定向光的貢獻做爲漫反射和鏡面反射項的總和,而後咱們經過將顏色映射的採樣乘以片斷的光照值來計算最終的顏色,最後咱們用剛剛計算出來的顏色和顏色映射的 alpha 通道的值做爲該片斷的 alpha 的值:

fragment float4 anchorGeometryFragmentLighting(ColorInOut in [[stage_in]],
                                               constant SharedUniforms &uniforms [[ buffer(3) ]]) {
    float3 normal = float3(in.normal);
    float3 directionalContribution = float3(0);
    {
        float nDotL = saturate(dot(normal, -uniforms.directionalLightDirection));
        float3 diffuseTerm = uniforms.directionalLightColor * nDotL;
        float3 halfwayVector = normalize(-uniforms.directionalLightDirection - float3(in.eyePosition));
        float reflectionAngle = saturate(dot(normal, halfwayVector));
        float specularIntensity = saturate(powr(reflectionAngle, uniforms.materialShininess));
        float3 specularTerm = uniforms.directionalLightColor * specularIntensity;
        directionalContribution = diffuseTerm + specularTerm;
    }
    float3 ambientContribution = uniforms.ambientLightColor;
    float3 lightContributions = ambientContribution + directionalContribution;
    float3 color = in.color.rgb * lightContributions;
    return float4(color, in.color.w);
}
複製代碼

若是你運行這個程序,你就能夠點擊屏幕並在實時攝像頭視圖中添加立方體,而後移動或靠近這些立方體觀察每一個面的不一樣顏色,就像這樣:

alt text
ARKit 1

在本系列的下一部分,咱們將會更深刻的研究 追蹤層場景解析層 並瞭解並瞭解平面檢測,撞擊測試,碰撞和物理效果如何使咱們的體驗更加豐富。 源代碼 已經發布到 GitHub

下次見!


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索