スマホやARグラスで仮想オブジェクトにUIKitを使いたいこともあるだろうと考え、UIView を SceneKit のシーン内に表示する方法を試してみた。
動作確認がしやすいのでUIViewとしては WKWebView
を使用。
参考情報:
・こちらのSwiftUIでの動作のツイート
シーン内での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レンダリングしながらキャプチャをするという過酷な処理となっているため、急速にメインキューが膨らんで画面が固まるという現象に遭遇した。キャプチャが終わるまで次のキャプチャをしないように対策。
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
// メインキューでの画面キャプチャが終わっていない場合はキャプチャしないようにする
// キャプチャ処理は遅いのでこれがないとキューがはけず固まる
guard !isCaptureWaiting else { return }
isCaptureWaiting = true
ソースコード全体
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
}
}