【翻译】使用 ARKit 与 Metal

增强现实(Augmented Reality)提供了一种在通常由移动设备的相机捕获的真实世界视图之上覆盖虚拟内容的方式。一个 ARKit 应用程序明确分为 3 层:追踪、场景理解和渲染。本文将主要看看在 Metal 里进行渲染。

August 7, 2017 -
ios arkit metal 翻译

本文翻译自 metalkit.org 上,Marius Horga 写的教程 Using ARKit with Metal

使用 ARKit 与 Metal


增强现实(Augmented Reality)提供了一种在通常由移动设备的相机捕获的真实世界视图之上覆盖虚拟内容的方式。上个月在 WWDC 2017,我们很兴奋地看到了 Apple 的新 ARKit 框架,这是一个高水平的 API,适用于运行 iOS 11 的 A9 设备或更新的设备上。一些我们已经看过的 ARKit 实验非常出色,例如下面这个:

ARKit

一个 ARKit 应用程序明确分为 3 层:

  • 追踪(Tracking) - 使用视觉惯性里程计(Visual-Inertial Odometry)进行世界追踪不需要额外设置。
  • 场景理解(Scene Understanding) - 使用平面检测、命中测试和光线估测来检测场景属性的能力。
  • 渲染(Rendering) - 由于 SpriteKit 和 SceneKit 提供模版 AR 视图,因此可以被轻松集成,但也可以为 Metal 定制。所有预渲染处理由 ARKit 完成,它还负责使用 AVFoundation 和 CoreMotion 来处理图像捕获。

在这一系列的第一部分,我们将主要看看在 Metal 里进行渲染,并谈谈这一系列里下一部分的另外两个阶段。在 AR 应用程序里,追踪和场景理解完全由 ARKit 框架处理,而渲染可以由 SpriteKit、SceneKit 或 Metal 来处理:

ARKit1.png

开始了,我们需要一个由 ARSessionConfiguration 对象设置的 ARSession 实例。然后,我们在这个配置里调用 run() 函数。这个会话同时也有 AVCaptureSession 和 CMMotionManager 对象在运行,用来得到图像和运动数据以便追踪。最后,这个会话将输出当前帧到一个 ARFrame 对象:

ARKit2.png

ARSessionConfiguration 对象含有关于会话将具有的追踪类型的信息。ARSessionConfiguration 基本配置类提供三自由度的追踪(设备方向),而其子类,ARWorldTrackingSessionConfiguration 提供六自由度的追踪(设备位置和方向)。

ARKit4.png

当一个设备不支持世界追踪时,它会回退到基本配置:

if ARWorldTrackingSessionConfiguration.isSupported {
    configuration = ARWorldTrackingSessionConfiguration()
} else {
    configuration = ARSessionConfiguration()
}

ARFrame 持有捕捉到的图像、追踪信息以及通过 ARAnchor 对象持有的关于真实世界的位置和方向信息的场景信息,在会话里这些都可轻易被添加、更新或移除。追踪是实时定位物理位置的能力。不过,世界追踪可以定位基于物理距离的位置和方向,它与起始位置有关,还提供 3D 特征点。

ARFrame 的最后一个组件是促成转换(翻转、旋转或缩放)的 ARCamera 对象,它还携带追踪状态和相机内部函数。追踪质量在很大程度上依赖于不间断的传感器数据和静态场景,并且当场景有大量复杂的纹理环境时会更准确。追踪状态有 3 个值:不可用(相机只有单位矩阵)、受限的(场景功能不足或不够静止)和正常(相机中填有数据)。相机输入不可用或追踪被停止会造成会话中断:

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 对象可以进行渲染。既然 SpriteKit 是 2D 的,它不能使用真实世界相机的位置,那么它投射锚点位置到 ARSKView,然后在投影位置将子画面(sprite)呈现为一个(平面)告示牌,所以子画面将一直面对相机。对于 Metal,没有定制的 AR 视图,所以责任落在了程序员的手中。为了处理渲染图像,我们需要:

  • 绘制相机背景图片(从像素缓冲里生成一个纹理)
  • 更新虚拟相机
  • 更新光线
  • 更新几何转换

所以这些信息都在 ARFrame 对象里。有两种方式可以访问到帧:轮询或使用代理。我们待会再解释。我拿到了 Metal 的 ARKit 模版,并将其剥离至最小化,所以我可以更好地了解它的工作方式。我做的第一件事就是移除全部的 C 依赖,所以不再需要桥接了。将来这会有用的,所以类型和枚举常量可以在 API 代码和着色器之间共享,但对于这篇文章的目的它不是必需的。

接下来,前往 ViewController,它将充当我们的 MTKView 和 ARSession 的代理。我们为实时更新应用程序,创建一个和代理一起工作的渲染器(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 {}

要拥有一个渲染器类的高级视图,这是伪代码:

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() 里我们更新帧共享的统一值(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() 里我们使用转换器更新当前帧的锚点统一值(uniform)缓存:

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;
}

在用于锚几何的第二对着色器里,在顶点着色器中,我们计算剪辑空间中顶点的位置,并输出用于裁剪和光栅化,然后将每一面着成不同的颜色,接着计算我们的顶点在眼睛可视空间里的位置,最后旋转法线(normals)到世界坐标:

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;
}

在这个片段着色器里,我们计算定向灯贡献的漫反射和镜面反射之和,然后我们通过将色彩图中的样本乘以片段的照明值来计算最终的颜色,最后使用我们刚计算出的颜色和该片段的阿尔法值的色彩图的阿尔法通道:

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);
}

如果你运行这个 app,你应该能点击屏幕,添加一个立方体在你的实时相机的视图之上,接着离开或关闭或围绕立方体来看它不同颜色的每一面,像这样:

ARKit1

在这一系列的下一部分,我们将深入了解追踪(Tracking)和场景理解(Scene Understanding),并看看如何检测平面,命中测试,碰撞和使我们的体验更美好的物理学。源代码照常发布在 GitHub 上。

期待下次!