Machineboy空

RealityKit의 Gesture를 살펴보자 - Pan, Rotate 본문

언어/iOS

RealityKit의 Gesture를 살펴보자 - Pan, Rotate

안녕도라 2024. 11. 11. 20:53

기본 제스처의 한계

  • 문제점1: 기본으로 제공하는 rotation의 경우 Y축 기준으로만 회전하는데, 상하로도 즉 X축 기준으로도 회전시켜 윗면을 보고 싶다!
  • 문제점2: 그리고 scale조정하는 제스처와 rotate 제스처가 같아 동작 구분이 필요하다.

 

Pinch VS Rotate VS Pan


문제점2: pinch와 rotate의 구분

 

 

두 손가락을 사용하는 pinch를 통해 scale을 조절하고 한 손가락을 사용하는 pan을 통해 rotate를 시키려니

많은 이들이 수차례 시행착오를 거쳐 완성했을 제스처 종류와 그에 따른 동작들에 대한 정립을 무시한 바보가 된 기분이 들었다.

 

이미 수차례 유저테스트를 거쳐 완성되었을 제스처와 동작간 매핑일 것이라 생각하여,

사용자가 가장 매끄럽게 사용하기 위해서는 기본 제스처들을 잘 사용하는 것이 맞겠다고 판단했다.

그래서 EntityGestures [.rotate, .scale]을 그대로 사용하기로 했다.

 

다만 scale의 경우, 물체가 프레임보다 커지는 경우나, 너무 작아져 살펴볼 수 없는 경우가 생기지 않도록 크기 제한을 두었다.

 


 

문제점1: x축 회전

 

남은 문제는 기본 rotation 제스처가 y축으로만 회전하여 좌우로 밖에 살펴볼 수 없다는 점인데,

내가 구현하고자 하는 기능은 box의 윗면도 충분히 살펴볼 수 있어야 하기 때문에,

제스처와 별개로 camera의 높이를 조절하는 기능을 추가했다.

 

rotate 제스처를 통한 좌우 회전과,카메라의 높이를 매핑한 슬라이더를 통해 윗면을 잘 살펴 볼 수 있게 구현하였다.

 

구현 코드

import SwiftUI
import ARKit
import RealityKit
import Combine

struct ContentView: View {

    @State private var cameraHeight: Float = 0.5    // 처음 카메라 높이
    
    var body: some View {
        VStack {
            ARViewContainer(cameraHeight: $cameraHeight)
                .ignoresSafeArea()
            
            // 카메라 높이 조절 슬라이더 0.1 ~ 1.0
            Slider(value: Binding(
                get: { Double(cameraHeight)},
                set: { cameraHeight = Float($0) }
            ), in: 0.5...1.0)
            .padding()
            .accentColor(.orange)
        
        }
    }
}

struct ARViewContainer: UIViewRepresentable {
    @Binding var cameraHeight: Float
    
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero, cameraMode: .nonAR, automaticallyConfigureSession: false)
        arView.environment.background = .color(.white)
        
        let cubeMaterial = SimpleMaterial(color: .orange, isMetallic: false)
        let cubeMesh = MeshResource.generateBox(size: 0.3)
        let cubeModel = ModelEntity(mesh: cubeMesh, materials: [cubeMaterial])
        
        cubeModel.generateCollisionShapes(recursive: true)
        
        let cubeAnchor = AnchorEntity(world: [0, 0, 0])
        cubeAnchor.addChild(cubeModel)
        arView.scene.anchors.append(cubeAnchor)
        
        // translation제외 제스체
        arView.installGestures([.rotation,.scale],for: cubeModel)
        
        context.coordinator.cubeModel = cubeModel
        
        // .nonAR일 때 가상 카메라가 있어야 화면 보임
        let camera = PerspectiveCamera()
        camera.position = [0, cameraHeight, 1]
        
        let cameraAnchor = AnchorEntity(world: [0, 0, 0])
        cameraAnchor.addChild(camera)
        arView.scene.addAnchor(cameraAnchor)
        
        context.coordinator.camera = camera
        
        // sceneEvent 구독
        context.coordinator.cancellable = arView.scene.subscribe(to: SceneEvents.Update.self) { _ in
            // camera가 cube바라보도록!
            camera.look(at: cubeModel.position, from: camera.position, relativeTo: nil)
            context.coordinator.clampModelSize()
        } as? AnyCancellable
        
        return arView
    }
    
    //View의 State변수에 Binding된 카메라 높이가 바뀔 때마다 호출됨
    func updateUIView(_ uiView: ARView, context: Context) {
        context.coordinator.camera?.position.y = cameraHeight
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }
}

class Coordinator: NSObject {
    var cubeModel: ModelEntity?
    var camera: PerspectiveCamera?
    var cancellable: AnyCancellable?
    
    func clampModelSize() {
        guard let model = cubeModel else { return }
        
        let currentScale = model.scale.x
        var newScale = currentScale
        
        if currentScale < 1.0 {
            newScale = max(currentScale, 1.0)
        }
        
        if currentScale > 2.5 {
            newScale = min(currentScale, 2.5 )
        }
        
        if newScale != currentScale {
            model.scale = SIMD3(repeating: newScale)
        }
    }
}

#Preview {
    ContentView()
}

 


수직 슬라이더 버전

 

이 화면을 넣어야 하는 부분의 아랫부분에 정보가 꽤 많아 가로 슬라이더를 배치할 경우,

화면이 복잡해질 것 같았다.

 

그리고 슬라이더를 통해 카메라의 높이를 높은대서 낮은 곳으로 이동하는 동작이니,

직관적으로 세로가 더 잘 맞겠다 싶어 GPT의 도움을 받아 만든 수직 슬라이더 코드이다!

import SwiftUI
import ARKit
import RealityKit
import Combine

struct ContentView: View {
    @State private var cameraHeight: Float = 0.5
    
    var body: some View {
        HStack {
            ARViewContainer(cameraHeight: $cameraHeight)
                .ignoresSafeArea()
            
            VerticalSlider(value: $cameraHeight, range: 0.5...2.0)
                .frame(width: 15, height: 300)
                .padding()
        }
    }
}


struct ARViewContainer: UIViewRepresentable {
    @Binding var cameraHeight: Float
    
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero, cameraMode: .nonAR, automaticallyConfigureSession: false)
        arView.environment.background = .color(.white)
        
        let cubeMaterial = SimpleMaterial(color: .orange, isMetallic: false)
        let cubeMesh = MeshResource.generateBox(size: 0.3)
        let cubeModel = ModelEntity(mesh: cubeMesh, materials: [cubeMaterial])
        
        cubeModel.generateCollisionShapes(recursive: true)
        
        let cubeAnchor = AnchorEntity(world: [0, 0, 0])
        cubeAnchor.addChild(cubeModel)
        
        arView.installGestures([.rotation, .scale], for: cubeModel)
        arView.scene.anchors.append(cubeAnchor)
        
        context.coordinator.cubeModel = cubeModel
        
        // .nonAR일 때 가상 카메라가 있어야 화면 보임
        let camera = PerspectiveCamera()
        camera.position = [0, cameraHeight, 1]
        
        let cameraAnchor = AnchorEntity(world: [0, 0, 0])
        cameraAnchor.addChild(camera)
        arView.scene.addAnchor(cameraAnchor)
        
        context.coordinator.camera = camera
        
        context.coordinator.cancellable = arView.scene.subscribe(to: SceneEvents.Update.self) { _ in
            camera.look(at: cubeModel.position, from: camera.position, relativeTo: nil)
            context.coordinator.clampModelSize()
        } as? AnyCancellable
        
        return arView
    }
    
    func updateUIView(_ uiView: ARView, context: Context) {
        context.coordinator.camera?.position.y = cameraHeight
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator()
    }
}

class Coordinator: NSObject {
    var cubeModel: ModelEntity?
    var camera: PerspectiveCamera?
    var cancellable: AnyCancellable?
    
    func clampModelSize() {
        guard let model = cubeModel else { return }
        
        let currentScale = model.scale.x
        var newScale = currentScale
        
        if currentScale < 0.5 {
            newScale = max(currentScale, 0.5)
        }
        
        if currentScale > 2.5 {
            newScale = min(currentScale, 2.5)
        }
        
        if newScale != currentScale {
            model.scale = SIMD3(repeating: newScale)
        }
    }
}


//세로 슬라이더
struct VerticalSlider: View {
    @Binding var value: Float
    var range: ClosedRange<Float>
    
    var body: some View {
        GeometryReader { geometry in
            VStack {
                Spacer(minLength: 0)
                Rectangle()
                    .fill(Color.orange)
                    .frame(height: CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) * geometry.size.height)
                    .cornerRadius(5)
            }
            .frame(width: geometry.size.width)
            .background(Color.gray.opacity(0.3))
            .cornerRadius(5)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { gesture in
                        let sliderHeight = geometry.size.height
                        let dragY = max(0, min(sliderHeight, sliderHeight - gesture.location.y))
                        let newValue = Float(dragY / sliderHeight) * (range.upperBound - range.lowerBound) + range.lowerBound
                        value = newValue
                    }
            )
        }
    }
}





#Preview {
    ContentView()
}

 

카메라와 큐브사이의 거리에 따른 보정값을 큐브사이즈에 곱해주고 싶은데.. 도전하다가 실패했다!

지금까지 구현한 걸 기본으로 좀 더 매끄럽게 다듬는 건 나중에!