RealityKit 自定义手势

2020-10-12

RealityKit 自定义手势#

RealityKit 为开发者提供了 EntityGestures,其中已经包含了 AR 应用中常见的手势,如移动、旋转和缩放。然而,我们有时候需要定制一些手势操作。比如,在模拟凸透镜成像实验时,我们需要限制光屏的移动始终在光具座上。幸运的是,ARView 本质上是一个 UIView,所以我们可以直接使用 UIGestureRecognizer 实现自定义的手势识别器。实际上,EntityGestures 提供的几种手势识别器也是继承 UIGestureRecognizer 的。

  • 对于 EntityGestures 的使用,可以参考这篇文章中的说明。
  • 对于 UIGestureRecognizer 的使用,可以参考这篇文章中的说明。

以 UIPanGestureRecognizer 为例#

我们以 UIPanGestureRecognizer 为基础,实现满足以下要求的手势:移动光具座上的光屏时,光屏始终处于光具座所在的直线上(如果直接使用 EntityTranslationGestureRecognizer,光屏可以被随意移动)。

let pan = UIPanGestureRecognizer(
    target: context.coordinator,
    action: #selector(context.coordinator.handlePan(_:))
)
arView.addGestureRecognizer(pan)
class Coordinator {
        private var arViewContainer: ARViewContainer
        private var hitEntity: Entity?
        
        init(_ arViewContainer: ARViewContainer) {
            self.arViewContainer = arViewContainer
        }
        
        @objc func handlePan(_ panGesture: UIPanGestureRecognizer) {
            guard let view = panGesture.view,
                  let arView = view as? ARView else {
                return
            }
            
            let touchLocation = panGesture.location(in: arView)
            
            switch panGesture.state {
            case .began:
                guard let hitEntity = arView.entity(
                        at: touchLocation) else {
                    // No entity was hit
                    return
                }
                self.hitEntity = hitEntity
            case .changed:
                if let hitEntity = hitEntity {
                    // TODO: Do something
                }
            default:
                hitEntity = nil
            }
        }
    }

handlePan(_:)#

该自定义手势的关键在于在 UIPanGestureRecognizer 的 delegate 中正确地处理每一次平移事件(即 handlePan(_:))。一个 UIGestureRecognizerstate 分为 .began, .changed, .failed, .canceled, .ended, .possible,对于我们来说,只需要关心 .began.changed 即可。

.began#

.began 代表着手势的开始阶段,因此我们需要检测该手势是否“命中”任何 entity,若不命中,则不应该触发任何移动效果。这里我们采用的是类似于 hit-testing 的操作来检测是否命中任何 entity。

.changed#

.began 阶段命中了任何 entry,即 self.hitEntity 不为空,那么我们就需要处理移动事件。对于我们的例子来说,我们可以作如下处理:

我们以正方体为例,假设该正方体始终位于三维空间内两点A、B所连接的线段之间。当我们拖动正方体时,我们可以实时转换出 A、B 两点对应于屏幕的 2D 坐标,同时,我们手指所在位置对应于屏幕的 2D 坐标也是已知的。于是,我们就可以通过公式:
$$
\mathrm{scale}=\frac{\overrightarrow{a}\cdot\overrightarrow{b}}{|a|^2}
$$
计算出 $b$ 向量在 $a$ 向量上的投影对应于 $a$ 向量的长度比例 scale,比如 $\mathrm{scale}=0.75$。很显然,这个比例也是三维空间中对应的比例。我们只需要通过 lerp 操作计算出三维空间中正方体应该所在的坐标即可。

示例代码如下。在这里,点 A、B 分别为 Board*_Start_BoundBoard*_End_Bound

注意,一定要在同一个坐标系下进行计算,RealityKit 中的 position 属性默认是相对坐标,因此需要手动取出世界坐标。

arView.project 方法可以直接将三维坐标转化为屏幕坐标系下的 2D 坐标。

case .changed:
    if let hitEntity = hitEntity {
        let entityName = hitEntity.name
        if (entityName == "Board1" || entityName == "Board2") {
            let start3DPoint = arView.scene.findEntity(named: entityName + "_Start_Bound")!.position(relativeTo: nil)
            let end3DPoint = arView.scene.findEntity(named: entityName + "_End_Bound")!.position(relativeTo: nil)
            let start2DPoint = arView.project(start3DPoint)!
            let end2DPoint = arView.project(end3DPoint)!
            
            let lineVector2D = SIMD2(startPoint: start2DPoint, endPoint: end2DPoint)
            let touchVector2D = SIMD2(startPoint: start2DPoint, endPoint: touchLocation)
            let scale = lineVector2D * touchVector2D / (lineVector2D * lineVector2D)
            
            let targetPosition = SIMD3.lerp(start: start3DPoint, end: end3DPoint, scale: scale)
            
            if (0...1).contains(scale) {
                hitEntity.setPosition(targetPosition, relativeTo: nil)
            }
        }
    }
SIMD Extension#

对于 production, lerp 等操作,SIMD 并未提供对应的实现,我们可以做简单的扩展:

//
//  Calculation+Extension.swift
//  ARPlayground
//
//  Created by 陈俊杰 on 2020/10/7.
//

import Foundation
import UIKit

// MARK: - SIMD2

extension SIMD2 where Scalar == Float {
    
    init(startPoint: CGPoint, endPoint: CGPoint){
        self.init(
            x: Scalar(endPoint.x - startPoint.x),
            y: Scalar(endPoint.y - startPoint.y)
        )
    }
    
    init(startPoint: SIMD2<Scalar>, endPoint: SIMD2<Scalar>){
        self.init(
            x: Scalar(endPoint.x - startPoint.x),
            y: Scalar(endPoint.y - startPoint.y)
        )
    }
    
    /// Production of two vector
    static func *(lhs: SIMD2<Scalar>, rhs: SIMD2<Scalar>) -> Scalar{
        lhs.x * rhs.x + lhs.y * rhs.y
    }
    
    /// Magnitude of the vector
    func magnitude() -> Scalar {
        sqrt(x * x + y * y)
    }
    
    static func lerp(start: SIMD2<Scalar>, end: SIMD2<Scalar>, scale: Scalar) -> SIMD2<Scalar>{
        let lineVector = SIMD2(startPoint: start, endPoint: end)
        
        return SIMD2<Scalar>(
            x: Scalar(start.x + scale * lineVector.x),
            y: Scalar(start.y + scale * lineVector.y)
        )
    }
}

// MARK: - SIMD3

extension SIMD3 where Scalar == Float {
    
    init(startPoint: SIMD3<Scalar>, endPoint: SIMD3<Scalar>){
        self.init(
            x: Scalar(endPoint.x - startPoint.x),
            y: Scalar(endPoint.y - startPoint.y),
            z: Scalar(endPoint.z - startPoint.z)
        )
    }
    
    /// Production of two vector
    static func *(lhs: SIMD3<Scalar>, rhs: SIMD3<Scalar>) -> Scalar{
        lhs.x * rhs.x + lhs.y * rhs.y + lhs.z * rhs.z
    }
    
    /// Magnitude of the vector
    func magnitude() -> Scalar {
        sqrt(x * x + y * y + z * z)
    }
    
    static func lerp(start: SIMD3<Scalar>, end: SIMD3<Scalar>, scale: Scalar) -> SIMD3<Scalar>{
        let lineVector = SIMD3(startPoint: start, endPoint: end)
        
        return SIMD3<Scalar>(
            x: Scalar(start.x + scale * lineVector.x),
            y: Scalar(start.y + scale * lineVector.y),
            z: Scalar(start.z + scale * lineVector.z)
        )
    }
}

.default#

在任何其他的手势状态中(如 .canceled, .failed 等),我们应该将 self.hitEntity 设置回空。

参考#

Getting started with RealityKit: Touch Gestures

Dragging objects in SceneKit and ARKit