Apple Engine

Apple, iPhone, iOS, その周辺のことについて

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)

f:id:x67x6fx74x6f:20170824192211g:plain

 

今回はここまで。