ARKit+SceneKit+Metal で Webブラウジング

スマホやARグラスで仮想オブジェクトにUIKitを使いたいこともあるだろうと考え、UIView を SceneKit のシーン内に表示する方法を試してみた。
動作確認がしやすいのでUIViewとしては `WKWebView` を使用。

完成イメージ
![demo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/96991a6d-507f-36d8-55c9-7ca62ce4a077.png) ![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/e4abc361-6e00-35de-cd2b-95c61d15528c.gif)

参考情報：
　・[こちらのSwiftUIでの動作のツイート](https://twitter.com/x67x6fx74x6f/status/1287105691845500930)

## シーン内でのUIViewの表示方法
① `UIView`をキャプチャし`MTLTexutre` に変換
②①をSceneKitの`SCNNode` のマテリアルに設定

これだけ。それっぽく動いているが、いくつかハマったところを以下に記載。

## どうやってWebViewをスクロールさせるか

仮想オブジェクトとなっているWebViewでスクロールやタップ処理をさせるには前面に `ARSCNView` がいる状態で、タッチイベントを任意のWebViewに渡す必要がある。
ここで`touchesBegan()` 等の `UIResponder` のイベントを `ARSCNView`で受け取って任意のWebViewの `touchesBegan()` 等に流す方法を思いついたが、これだとスクロールもタップも全く動作せず。そもそもUIEventを渡したところでスクロールをさせたりボタンタップをさせる、といったUI部品固有のアクションをさせることはできるのか？という気がしてきた。この辺り知識不足。。。ご存知の方がいたら教えて欲しいです。
プログラムからスクロールさせる方法をググると `UIScrollView` の `contentOffset` で設定するやりかたはでてくるが、これだと心地よい慣性スクロールはできないはず。
で、結局、良い方法が見つからず次の方法で実現。

・`ARSCNView`と同じサイズ同じ位置に WebView を配置
・`ARSCNView` は前面にしておき `isUserInteractionEnabled` は falseにする
・ヒットテストを行い、画面の中央にある WebView の `isUserInteractionEnabled` をtrueにする

これで任意のWebViewのスクロールは可能になる。
ただし、この方法には次の欠点がある。

・スクロールさせる場合、WebView を任意のアスペクト比にできない。画面サイズに合わせる必要がある。
・タップされた位置をシーン内のWebViewの位置に補正できないので、ボタンクリック等のイベント処理はできない（使い物にならない）

上記より、この方法が使えるケースが使えるのは、画面内の仮想オブジェクトにおいて、何かしらの情報を表示のみ、操作はスクロールのみ、というケースに限定されると思われる。
前述のツイートのようにSwiftUIでどのようにできるかまた試す予定。

## 画面キャプチャが遅くてレンダリングが追いつかない

キャプチャのタイミングを `SCNSceneRendererDelegate` の `renderer(_:updateAtTime:)` としているが、メインスレッドで画面をキャプチャ＆SceneKitに渡す必要があるため、このメソッドのなかでメインキューにキャプチャ処理をエンキューしている。画面キャプチャが十分に高速であればこれだけでも良いかもしれないが、ARでトラッキングをしながら3Dレンダリングしながらキャプチャをするという過酷な処理となっているため、急速にメインキューが膨らんで画面が固まるという現象に遭遇した。キャプチャが終わるまで次のキャプチャをしないように対策。

```swift
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
    // メインキューでの画面キャプチャが終わっていない場合はキャプチャしないようにする
    // キャプチャ処理は遅いのでこれがないとキューがはけず固まる
    guard !isCaptureWaiting else { return }
    isCaptureWaiting = true

```

## ソースコード全体

```swift
class ViewController: UIViewController, ARSCNViewDelegate {

    @IBOutlet weak var scnView: ARSCNView!
    @IBOutlet weak var webView1: WKWebView!
    @IBOutlet weak var webView2: WKWebView!
    @IBOutlet weak var webView3: WKWebView!
    
    private var device = MTLCreateSystemDefaultDevice()!
    private var planeNode1: SCNNode!
    private var planeNode2: SCNNode!
    private var planeNode3: SCNNode!

    private var node1Texture: MTLTexture?
    private var node2Texture: MTLTexture?
    private var node3Texture: MTLTexture?

    private var viewWidth: Int = 0
    private var viewHeight: Int = 0

    private var isCaptureWaiting = false

    override func viewDidLoad() {
        super.viewDidLoad()
        scnView.scene = SCNScene(named: "art.scnassets/sample.scn")!
        planeNode1 = scnView.scene.rootNode.childNode(withName: "plane1", recursively: true)
        planeNode2 = scnView.scene.rootNode.childNode(withName: "plane2", recursively: true)
        planeNode3 = scnView.scene.rootNode.childNode(withName: "plane3", recursively: true)
        // UIイベントはいったん、受け付けないようにする
        scnView.isUserInteractionEnabled = false
        setUIEnable(webView1: false, webView2: false, webView3: false)
        // ARSCNViewは常に前面に表示
        self.view.bringSubviewToFront(scnView)
        // AR Session 開始
        self.scnView.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        if node1Texture == nil || node2Texture == nil || node3Texture == nil {
            viewWidth = Int(view.bounds.width)
            viewHeight = Int(view.bounds.height)
            // テクスチャバッファを確保
            // WebViewのサイズ = self.viewのサイズ としている
            let desc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
                                                                width: viewWidth,
                                                                height: viewHeight,
                                                                mipmapped: false)
            node1Texture = device.makeTexture(descriptor: desc)!
            node2Texture = device.makeTexture(descriptor: desc)!
            node3Texture = device.makeTexture(descriptor: desc)!
            // サイト読み込み
            webView1.load(URLRequest(url: URL(string:"https://qiita.com")!))
            webView2.load(URLRequest(url: URL(string:"https://www.apple.com")!))
            webView3.load(URLRequest(url: URL(string:"https://stackoverflow.com")!))
        }
    }
    
    // 各WebViewの isUserInteractionEnabled 設定
    func setUIEnable(webView1: Bool, webView2: Bool, webView3: Bool) {
        self.webView1.isUserInteractionEnabled = webView1
        self.webView2.isUserInteractionEnabled = webView2
        self.webView3.isUserInteractionEnabled = webView3
    }
    
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        guard let _node1Texture = node1Texture, let _node2Texture = node2Texture, let _node3Texture = node3Texture else { return }
        // メインキューでの画面キャプチャが終わっていない場合はキャプチャしないようにする
        // キャプチャ処理は遅いのでこれがないとキューがはけず固まる
        guard !isCaptureWaiting else { return }
        isCaptureWaiting = true
        
        DispatchQueue.main.async {
            // ノードのヒットテストを行い画面中央にあるWebView(ノード)を見つける
            let bounds = self.scnView.bounds
            let screenCenter =  CGPoint(x: bounds.midX, y: bounds.midY)
            let options: [SCNHitTestOption: Any] = [
                .boundingBoxOnly: true,     // boundingBoxでテスト
                .firstFoundOnly: true       // いちばん手前のオブジェクトのみ返す
            ]
            if let hitResult = self.scnView.hitTest(screenCenter, options: options).first,
               let nodeName = hitResult.node.name {
                // 画面中央にあるノードに対応するWebViewの isUserInteractionEnabled を true にする
                switch nodeName {
                case "plane1":
                    self.setUIEnable(webView1: true, webView2: false, webView3: false)
                case "plane2":
                    self.setUIEnable(webView1: false, webView2: true, webView3: false)
                case "plane3":
                    self.setUIEnable(webView1: false, webView2: false, webView3: true)
                default:
                    self.setUIEnable(webView1: false, webView2: false, webView3: false)
                }
            } else {
                self.setUIEnable(webView1: false, webView2: false, webView3: false)
            }
            
            // 画面中央にあるノードに対応するWebViewのみキャプチャしてノードのマテリアルを更新
            let setNodeMaterial: (UIView, SCNNode, MTLTexture) -> () = { captureView, node, texture in
                let material = SCNMaterial()
                material.diffuse.contents = captureView.takeTextureSnapshot(device: self.device,
                                                                            textureWidth: self.viewWidth,
                                                                            textureHeight: self.viewHeight,
                                                                            textureBuffer: texture)
                node.geometry?.materials = [material]
            }

            if self.webView1.isUserInteractionEnabled {
                setNodeMaterial(self.webView1, self.planeNode1, _node1Texture)
            } else if self.webView2.isUserInteractionEnabled {
                setNodeMaterial(self.webView2, self.planeNode2, _node2Texture)
            } else if self.webView3.isUserInteractionEnabled {
                setNodeMaterial(self.webView3, self.planeNode3, _node3Texture)
            }
            
            self.isCaptureWaiting = false
        }
    }
}

extension UIView {
    // 任意のUIViewの画面キャプチャをして MTLTexture に変換
    // 参考URL : https://stackoverflow.com/questions/61724043/render-uiview-contents-into-mtltexture
    func takeTextureSnapshot(device: MTLDevice, textureWidth: Int, textureHeight: Int, textureBuffer: MTLTexture) -> MTLTexture? {
        
        guard let context = CGContext(data: nil,
                                   width: textureWidth,
                                   height: textureHeight,
                                   bitsPerComponent: 8,
                                   bytesPerRow: 0,
                                   space: CGColorSpaceCreateDeviceRGB(),
                                   bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
        // 上下反転の回避
        context.translateBy(x: 0, y: CGFloat(textureHeight))
        context.scaleBy(x: 1, y: -1)
        
        guard let data = context.data else { return nil }
        layer.render(in: context)
        
        textureBuffer.replace(region: MTLRegionMake2D(0, 0, textureWidth, textureHeight),
                              mipmapLevel: 0,
                              withBytes: data,
                              bytesPerRow: context.bytesPerRow)
        return textureBuffer
    }
}

```