Xcode 12.5.1 SwiftUI ARKit RealityKit MacOS 11.5.1
本demo只是为了学习realitykit的使用,实际如果制作AR测距,推荐使用SceneKit来描绘点和线与文字。
效果如图
SwiftUI+RealityKit实现简单AR测距
首先在Xcode新建一个工程,选择Augmented Reality App.建好之后,会有默认的一个3d模型文件和一段默认的ARViewContainer的代码,运行后,当屏幕中心点找到水平面后,会显示一个立方体模型。
我们可以不用这个模型,用我们自己的代码替代
struct ARViewContainer: UIViewRepresentable {
@EnvironmentObject var vm: ViewModel
func makeUIView(context: Context) -> some UIView {
return vm.arView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
还是用熟悉的MVVM模式来设计我们的app,新建一个swift文件,起名为ViewModel.swift
import SwiftUI
import RealityKit
import ARKit
class ViewModel: ObservableObject {
// static let shared = ViewModel()
@Published var arView: ARView!
@Published var position: CGPoint = .zero
// MARK: - 记录屏幕上设置的点的位置
@Published var simd_position: [simd_float4x4] = []
// MARK: - 测量的距离
@Published var distance: Float = 0.0
// MARK: - 移动屏幕时定时发送过来的点,在有新的点时,旧的点要remove(anchor)
var moveAnchorEntity: AnchorEntity?
// MARK: - 移动屏幕时画的线,在有新的点时,旧的线要remove(anchor)
var lineAnchorEntity: AnchorEntity?
var sphere: ModelEntity?
init() {
arView = ARView(frame: .zero)
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal, .vertical]//.horizontal
arView.session.run(configuration)
}
}
arView是ARKit的主视图,在初始化时初始化为可跟踪镜头的配置模式
测距我采用的方式是,第一个点使用手机屏幕的中心点,设置完第一个点后,启动定时器,实时将屏幕中心点传递给ViewModel,让ARKit计算实际物理世界中的点,并显示在arView中,同时画出两个点的连线。
苹果自带的测距仪App我不知道是不是用这种原理实现,因为我也是初次接触ARKit,所以只能用这种笨方法。
以下是View视图ContentView的源码
import SwiftUI
import RealityKit
import Combine
struct ContentView : View {
@StateObject var vm: ViewModel = ViewModel()
@State var timer = Timer.publish(every: 0.1, on: .main, in: .common)
@State var cancellable: Cancellable? = nil
var body: some View {
ZStack {
ARViewContainer()
.edgesIgnoringSafeArea(.all)
.environmentObject(vm)
VStack {
HStack {
Spacer()
ClearButton.padding()
}
Spacer()
Text("测距: \(vm.distance) 米")
.foregroundColor(.white)
.font(.title).bold()
AddButton
}
}
.onReceive(timer, perform: timerHandler())
}
}
extension ContentView {
// MARK: - 清除按钮
var ClearButton: some View {
Button(action: {
cancellable?.cancel()
cancellable = nil
vm.clearScreen()
}, label: {
Image(systemName: "trash.circle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
.foregroundColor(.white)
})
}
// MARK: - 加号按钮
var AddButton: some View {
Button(action: {
if cancellable == nil {
// 第一次: 设置起始点,开始计时器
vm.addStartNode(CGPoint(x: UIScreen.main.bounds.midX, y: UIScreen.main.bounds.midY))
timer = Timer.publish(every: 0.1, on: .main, in: .common)
cancellable = timer.connect()
} else {
// 第二次: 设置结束点,停止计时器
cancellable?.cancel()
cancellable = nil
}
}, label: {
Image(systemName: "plus.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundColor(.white)
})
}
// MARK: - 计时器处理方法
func timerHandler() -> (Timer.TimerPublisher.Output) -> Void {
return { _ in
vm.moveNode(CGPoint(x: UIScreen.main.bounds.midX, y: UIScreen.main.bounds.midY))
}
}
}
struct ARViewContainer: UIViewRepresentable {
@EnvironmentObject var vm: ViewModel
func makeUIView(context: Context) -> some UIView {
return vm.arView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
以下是ViewModel的源码:
import Foundation
import SwiftUI
import RealityKit
import ARKit
import TextEntity
class ViewModel: ObservableObject {
// static let shared = ViewModel()
@Published var arView: ARView!
@Published var position: CGPoint = .zero
// MARK: - 记录屏幕上设置的点的位置
@Published var simd_position: [simd_float4x4] = []
// MARK: - 测量的距离
@Published var distance: Float = 0.0
// MARK: - 移动屏幕时定时发送过来的点,在有新的点时,旧的点要remove(anchor)
var moveAnchorEntity: AnchorEntity?
// MARK: - 移动屏幕时画的线,在有新的点时,旧的线要remove(anchor)
var lineAnchorEntity: AnchorEntity?
var sphere: ModelEntity?
init() {
arView = ARView(frame: .zero)
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = [.horizontal, .vertical]//.horizontal
arView.session.run(configuration)
}
}
// MARK: - Public Extension
extension ViewModel {
/// 放置初始点
/// - 放置后开始追踪屏幕中心点,并连线
/// - Parameters:
/// - p: CGPoint 屏幕坐标
/// - Returns: 无
public func addStartNode(_ p: CGPoint) {
// MARK: - 设置初始点前先清空可能存在的上一对连接点和线
clearScreen()
// MARK: - 转换屏幕坐标为物理世界坐标
let query = arView.makeRaycastQuery(from: p, allowing: .existingPlaneInfinite, alignment: .any)
if let firstResult = query {
let results = arView.session.raycast(firstResult)
if let position = results.first?.worldTransform {
drawNode(position)
// MARK: - 存储到simd_position
simd_position.append(position)
}
}
}
/// 放置结束点
/// - 在初始点放置后,可以放置结束点,放置后显示两点连线
/// - Parameters:
/// - p: CGPoint 屏幕坐标
/// - Returns: 无
public func addEndNode(_ p: CGPoint) {
// MARK: - 转换屏幕坐标为物理世界坐标
let query = arView.makeRaycastQuery(from: p, allowing: .existingPlaneInfinite, alignment: .any)
if let firstResult = query {
let results = arView.session.raycast(firstResult)
if let position = results.first?.worldTransform {
drawNode(position)
// MARK: - 存储到simd_position
simd_position.append(position)
}
}
}
/// 移动结束点
/// - 初始点设置完后,如果屏幕中心点变化,在物理世界中追踪这个变化
/// - Parameters:
/// - p: simd_float4x4 物理世界坐标
/// - Returns: 无
public func moveNode(_ p: CGPoint) {
let query = arView.makeRaycastQuery(from: p, allowing: .existingPlaneInfinite, alignment: .any)
if let firstResult = query {
let results = arView.session.raycast(firstResult)
if let position = results.first?.worldTransform {
let mesh = MeshResource.generatePlane(width: 0.005, depth: 0.005, cornerRadius: 1)
let material = SimpleMaterial(color: .orange, isMetallic: false)
let modelEntity = ModelEntity(mesh: mesh, materials: [material])
if moveAnchorEntity != nil {
arView.scene.removeAnchor(moveAnchorEntity!)
}
moveAnchorEntity = AnchorEntity(world: position)
moveAnchorEntity!.addChild(modelEntity)
arView.scene.addAnchor(moveAnchorEntity!)
self.drawMoveLine(simd_position[0], position)
}
}
}
/// 清除已有的点和线
/// - 清空对象:simd_position数组 arView.scene.anchors序列
/// - Returns: 无
public func clearScreen() {
simd_position.removeAll()
arView.scene.anchors.removeAll()
}
}
// MARK: - Private Extension
extension ViewModel {
/// 在物理世界画一个点
/// - 用generatePlane + SimpleMaterial方式只能设置为灰色 ???
/// - Parameters:
/// - p: simd_float4x4 物理世界坐标
/// - Returns: 无
private func drawNode(_ p: simd_float4x4) {
let mesh = MeshResource.generatePlane(width: 0.005, depth: 0.005, cornerRadius: 1)
let material = SimpleMaterial(color: .orange, isMetallic: true)
let modelEntity = ModelEntity(mesh: mesh, materials: [material])
let anchorEntity = AnchorEntity(world: p)
anchorEntity.addChild(modelEntity)
arView.scene.addAnchor(anchorEntity)
}
/// 根据给定的两点画线
/// ```
/// - 屏幕只保留一条线,之前画的线在新画前要清除
/// -- startPoint: simd_float4x4 起始点
/// -- endPoint: simd_float4x4 结束点
private func drawMoveLine(_ startPoint: simd_float4x4, _ endPoint: simd_float4x4) {
let s = startPoint
let e = endPoint
let start = startPoint.columns.3
let end = endPoint.columns.3
/// 判断当前的anchor集合中是否包含lineAnchorEntity
/// 如果包含则remove
let _ = arView.scene.anchors.contains { anchor in
if anchor == lineAnchorEntity {
arView.scene.removeAnchor(anchor)
return true
}
return false
}
// MARK: box的深度,深度为两个点的距离,依次来形成线
let meters = simd_distance(start, end)
// MARK: 连线,用box形式显示
let rectangle = ModelEntity(mesh: .generateBox(width: 0.0008, height: 0.0008, depth: meters), materials: [SimpleMaterial(color: UIColor(.orange), isMetallic: false)])
// MARK: 两点的中心点
let middlePoint : simd_float3 = simd_float3((start.x + end.x)/2, (start.y + end.y)/2, (start.z + end.z)/2)
lineAnchorEntity = AnchorEntity()
lineAnchorEntity!.position = middlePoint
let startPoint = SIMD3(start.x, start.y, start.z)
// MARK: Positions and orients the entity to look at a target from a given position.
// 定位和定向实体以从给定位置看目标。 ( ??? )
lineAnchorEntity!.look(at: startPoint, from: middlePoint, relativeTo: nil)
lineAnchorEntity!.addChild(rectangle)
arView.scene.addAnchor(lineAnchorEntity!)
calcuteMoveDistance(s, e)
}
private func addLineNode(start: simd_float4, end: simd_float4) {
// MARK: box的深度,深度为两个点的距离,依次来形成线
let meters = simd_distance(start, end)
// MARK: 连线,用box形式显示
let rectangle = ModelEntity(mesh: .generateBox(width: 0.0008, height: 0.0008, depth: meters), materials: [SimpleMaterial(color: UIColor(.orange), isMetallic: false)])
// MARK: 两点的中心点
let middlePoint : simd_float3 = simd_float3((start.x + end.x)/2, (start.y + end.y)/2, (start.z + end.z)/2)
lineAnchorEntity = AnchorEntity()
lineAnchorEntity!.position = middlePoint
let startPoint = SIMD3(start.x, start.y, start.z)
// MARK: Positions and orients the entity to look at a target from a given position.
// 定位和定向实体以从给定位置看目标。 ( ??? )
lineAnchorEntity!.look(at: startPoint, from: middlePoint, relativeTo: nil)
lineAnchorEntity!.addChild(rectangle)
arView.scene.addAnchor(lineAnchorEntity!)
}
/// 计算物理世界两点距离
/// ```
/// - 无
/// -- startPoint: simd_float4x4 起始点
/// -- endPoint: simd_float4x4 结束点
func calcuteMoveDistance(_ startPoint: simd_float4x4, _ endPoint: simd_float4x4) {
let start = startPoint
let end = endPoint
distance = sqrt(
pow(end.columns.3.x - start.columns.3.x, 2) +
pow(end.columns.3.y - start.columns.3.y, 2) +
pow(end.columns.3.z - start.columns.3.z, 2)
)
}
/// 根据给定的两点画线
/// ```
/// - startPoint: simd_float4x4 起始点
/// - endPoint: simd_float4x4 结束点
private func drawLine(_ startPoint: simd_float4x4, _ endPoint: simd_float4x4) {
let start = startPoint.columns.3
let end = endPoint.columns.3
// MARK: box的深度,深度为两个点的距离,依次来形成线
let meters = simd_distance(start, end)
// MARK: 连线,用box形式显示
let rectangle = ModelEntity(mesh: .generateBox(width: 0.003, height: 0.003, depth: meters), materials: [SimpleMaterial(color: UIColor(.blue), isMetallic: false)])
// MARK: 两点的中心点
let middlePoint : simd_float3 = simd_float3((start.x + end.x)/2, (start.y + end.y)/2, (start.z + end.z)/2)
let lineAnchor = AnchorEntity()
lineAnchor.position = middlePoint
let startPoint = SIMD3(start.x, start.y, start.z)
// MARK: Positions and orients the entity to look at a target from a given position.
// 定位和定向实体以从给定位置看目标。 ( ??? )
lineAnchor.look(at: startPoint, from: middlePoint, relativeTo: nil)
lineAnchor.addChild(rectangle)
arView.scene.addAnchor(lineAnchor)
}
}
需要注意的一点是,我们需要增加一个Launch Screen.storyboard,否则AR无法全屏显示。
|