iOS で SceneKit を試す(Swift 3) その79 - 画面操作などからオブジェクトを探し出すヒットテストについて
今回は、特定のオブジェクトを SCNView から探し出すヒットテストをみていく。
Xcode の Game テンプレートではレンダリングされた画像から調べ、
宇宙船をタップするとマテリアルの色が赤く変わるアニメーションが実装されている。
(ドキュメントではレンダリングされた画像と書いてあるが、カメラからレイを飛ばして調べているのだと思われる)
ヒットテストで検索が終わり、ぶつかった対象が見つかると、ノードの情報などを持った SCNHitTestResult を返す。
SceneKit で用意されているヒットテスト
ヒットテストの検索を実行できるメソッドは3つ。 レンダリング画像 (SCNSceneRenderer)、SCNNode、物理シミュレーションを使用した PhysicsWorld 内で調べることができる。
- hitTest(_:options :)
- hitTestWithSegment(from:to:options :)
- rayTestWithSegment(from:to:options :)
hitTest
シーンをレンダリングした画像内でタップされたポイントに対応するオブジェクトを検索する。 画面操作からオブジェクトを探す場合など。
hitTestWithSegment
指定された2点間の線分を交差するオブジェクトのノードのチルドノード内を検索する。 キャラクタがある線を超えた時など。
rayTestWithSegment
PhysicsWorld 内で2点間の線分を交差する PhysicsBody を検索する。 視線のようなものができるため、敵キャラの領域にプレイヤーが侵入した際の処理など。
ヒットテストのオプションとは?
オブジェクトを調べる際に、チルドノードを無視するなど、 どのように調べるかその方法を決める。
hitTest、hitTestWithSegment と rayTestWithSegment では設定する値が異なるので注意。
hitTest、hitTestWithSegment
rootNode 以外の値は Bool を含む NSNumber。
オプション名 | デフォルト値 | 説明 |
---|---|---|
backFaceCulling | true | カメラに向いてない面を無視する。ジオメトリの裏面など |
boundingBoxOnly | false | バウンディングボックスのみでオブジェクトを検索。検索範囲が荒くなるがパフォーマンスが向上する |
categoryBitMask | categoryBitMask に適合するものを検索する。デフォルト値は全て適応する | |
clipToZRange | true | カメラの zNear と zFardistances の間のオブジェクトのみを検索 |
firstFoundOnly | false | 見つかった最初のオブジェクトのみを返す。一番近くにあるものではないので注意 |
ignoreChildNodes | false | チルトノードを無視する |
ignoreHiddenNodes | true | 表示していないノードを無視する。カメラからジオメトリの重なりで見えなくなっているものではなく、ノードの hidden のプロパティを ture しているもの |
rootNode | ルートノード及び設定したノード | 検索開始するルートのノードを決める。渡す値は SCNNode。hitTestWithSegment は設定したノードが検索対象になる |
sortResults | true | ヒットテストの結果をソートする。false の場合は結果を任意の順序で返される |
SCNPhysicsWorld.TestOption
オプション名 | デフォルト値 | 説明 |
---|---|---|
backfaceCulling | false | 裏側の衝突対象にするか否か。 |
collisionBitMask | categoriesBitMask に適合するものだけ検索する。デフォルト値は全て適応する | |
searchMode | any | どのように検索するかを決める。パラメーターは以下参照 |
SearchMode
- all - パラメータに一致するすべてを返す
- any - 位置に関係なく最初のものを返す
- closest - 最も近いものを返す
ヒットテスト後 SCNHitTestResult が返す値
node
検索に適合したジオメトリのノードを返す。
geometryIndex
SCNGeometryElement のインデックスを返す。
ノード内のジオメトリ要素が複数のない場合は初めの 0 を返す。
チルドノードではないので注意。
faceIndex
ジオメトリの面のインデックスを返す。
localCoordinates
検索時にヒットした場所のローカル座標を返す。
worldCoordinates
検索時にヒットした場所のワールド座標を返す。
localNormal
検索時にヒットした面の法線ベクトルをローカル座標で返す。
worldNormal
検索時にヒットした面の法線ベクトルをワールド座標で返す。
modelTransform
検索時にヒットしたノードのワールド座標を行列 (SCNMatrix4) で返す。
textureCoordinates(withMappingChannel:)
テクスチャマッピングの UV 座標を返す。
UV を使用している チャネル を指定する必要があるので注意。
例えば銃を撃たれた際、表面に銃痕をつけるなどで使用する。
Game テンプレートの hitTest はどのように動いているのか
Xcode の SceneKit の Game テンプレートは以下のような流れ。
GameViewController.swift の viewDidLoad() で、UITapGestureRecognizer からタップのジェスチャーを呼び、SCNView がタップされるとアクションから handleTap(_:) が呼ばれる
// add a tap gesture recognizer let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) scnView.addGestureRecognizer(tapGesture)
handleTap では GestureRecognize の location から画面の位置を渡し、SCNView から hitTest の関数を読んでいる。
func handleTap(_ gestureRecognize: UIGestureRecognizer) { // retrieve the SCNView let scnView = self.view as! SCNView // check what nodes are tapped let p = gestureRecognize.location(in: scnView) let hitResults = scnView.hitTest(p, options: [:]) ・ ・ ・ }
検索が成功すると hitResults に SCNHitTestResult が配列で渡されるので、最初のものを呼び出して、ノードを取得し、マテリアル情報を取得
・ ・ ・ if hitResults.count > 0 { // retrieved the first clicked object let result: AnyObject = hitResults[0] // get its material let material = result.node!.geometry!.firstMaterial! ・ ・ ・
SCNTransaction でマテリアルの色を変更して、completionBlock で色を戻しているいる。
・ ・ ・ // highlight it SCNTransaction.begin() SCNTransaction.animationDuration = 0.5 // on completion - unhighlight SCNTransaction.completionBlock = { SCNTransaction.begin() SCNTransaction.animationDuration = 0.5 material.emission.contents = UIColor.black SCNTransaction.commit() } material.emission.contents = UIColor.red SCNTransaction.commit() } }
SCNHitTestResult の他のパラメータを見てみる
Game テンプレートを使用して見ていく。
宇宙船が回転しているとタップしづらいので GameViewController.swift の 以下の部分(44行目あたり)を消す。
// retrieve the ship node let ship = scene.rootNode.childNode(withName: "ship", recursively: true)! // animate the 3d object ship.runAction(SCNAction.repeatForever(SCNAction.rotateBy(x: 0, y: 2, z: 0, duration: 1)))
func handleTap(_ gestureRecognize: UIGestureRecognizer) { 〜 の let material = result.node!.geometry!.firstMaterial! の下に以下のものを書くと、ビルド後に宇宙船をタップすると、デバッガーコンソール、情報が出力される。
// get its material let material = result.node!.geometry!.firstMaterial! print("\n----") print("name: \(String(describing: result.node!.name))") print("geometryIndex: \(result.geometryIndex)") print("faceIndex: \(result.faceIndex)") print("localCoordinates: \(result.localCoordinates)") print("worldCoordinates: \(result.worldCoordinates)") print("localNormal: \(result.localNormal)") print("worldNormal: \(result.worldNormal)") print("modelTransform: \(result.modelTransform)") print("textureCoordinates: \(result.textureCoordinates(withMappingChannel:0))")
試しに SCNHitTestResult のパラメーターを使ってみる
先ほどの print 文の下に以下のものを書いて実行すると、宇宙船をタップすると SCNSphere が付着する。
とりあえず、今回はワールド座標に設置していて、球の法線に合わせて向きは変えてない。
// 球の半径 let radius:Float = 0.2 // 球のノード作成 let node = SCNNode(geometry: SCNSphere(radius: CGFloat(radius))) // 球のマテリアルの色を設定 node.geometry?.firstMaterial?.diffuse.contents = UIColor(red: 1.0, green: 0.0, blue: 0.501960784313725, alpha: 1.0) // result.worldCoordinates でジオメトリをタップした場所を取得し SCNMatrix4 へ let pos = SCNMatrix4MakeTranslation( result.worldCoordinates.x, result.worldCoordinates.y, result.worldCoordinates.z ) // サーフェイスの法線から球の半径分移動したベクトルを SCNMatrix4 へ let normal = SCNMatrix4MakeTranslation( result.worldNormal.x * radius, result.worldNormal.y * radius, result.worldNormal.z * radius ) // 球のピボットが中心にあるため、ジオメトリの場所に半径分移動させている node.transform = SCNMatrix4Mult(pos, normal) scnView.scene?.rootNode.addChildNode(node)
今回はここまで。