4
votes

I'm using SceneKit's physicsBody system to detect collisions between objects, and getting some very strange results. To illustrate, I've got a minimal example that produces two spheres with kinematic physicsBodies and moves them in straight lines so that they briefly overlap.

I would expect to see physicsWorld(:didBeginContact:) called exactly once, when the spheres first overlap, and physicsWorld(:didEndContact:) called once when they stop overlapping. Instead, I'm seeing each function called 25 times!

Here's the code to reproduce: In Xcode 8.0, create a brand new project using the "Game" template. Replace the contents of GameViewController.swift with this:

import UIKit
import SceneKit

class GameViewController: UIViewController, SCNSceneRendererDelegate, SCNPhysicsContactDelegate {

    var scnScene: SCNScene!
    var scnView: SCNView!
    var cameraNode: SCNNode!

    var nodeA: SCNNode!
    var nodeB: SCNNode!

    var countBeginnings: Int = 0
    var countEndings: Int = 0

    override func viewDidLoad() {
        super.viewDidLoad()

        setupScene()
        setupNodes()
    }

    func setupScene() {
        // create a new SCNScene and feed it to the view
        scnView = self.view as! SCNView
        scnScene = SCNScene()
        scnView.scene = scnScene

        // assign self as SCNView delegate to get access to render loop
        scnView.delegate = self
        // assign self as contactDelegate to handle collisions
        scnScene.physicsWorld.contactDelegate = self

        // create the camera and position it at origin
        cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3Zero
        scnScene.rootNode.addChildNode(cameraNode)

        // tell scnView to update every frame
        scnView.isPlaying = true
    }

    func setupNodes() {
        // create two spheres with physicsBodies, one inside the other
        nodeA = SCNNode()
        nodeA.name = "Node A"
        nodeA.geometry = SCNSphere(radius: 1.0)
        nodeA.geometry!.firstMaterial?.diffuse.contents = UIColor.yellow.withAlphaComponent(0.6)
        // expected behavior
        // nodeA.position = SCNVector3(x: 0.0, y: -0.8, z: -10.0)
        // weird behavior
        nodeA.position = SCNVector3(x: 0.0, y: -0.9, z: -10.0)
        nodeA.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: nodeA.geometry!, options: nil))
        scnScene.rootNode.addChildNode(nodeA)

        nodeB = SCNNode()
        nodeB.name = "Node B"
        nodeB.geometry = SCNSphere(radius: 0.5)
        nodeB.geometry!.firstMaterial?.diffuse.contents = UIColor.red
        nodeB.position = SCNVector3(x: -2.0, y: 0.0, z: -10.0)
        nodeB.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: nodeB.geometry!, options: nil))
        scnScene.rootNode.addChildNode(nodeB)

        // node A can collide with node B but not the other way around
        nodeA.physicsBody!.categoryBitMask = 2
        nodeB.physicsBody!.categoryBitMask = 1
        nodeA.physicsBody!.contactTestBitMask = 1
    }

    func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
        countBeginnings += 1
        print("(" + String(countBeginnings) + ") " + contact.nodeA.name! + " began contact with " + contact.nodeB.name!)
    }
    func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) {
        countEndings += 1
        print("(" + String(countEndings) + ") " + contact.nodeA.name! + " ended contact with " + contact.nodeB.name!)
    }

    var frameNumber = 0
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        nodeB.position.x += 0.01
        nodeB.position.y -= 0.01
    }

}

There's other weirdness going on too. If I change the initial position of one of the spheres just a little bit, moving the y position from -0.9 to -0.8:

nodeA.position = SCNVector3(x: 0.0, y: -0.8, z: -10.0)

Now I get the expected behavior, one call to begin and one call to end! A slightly different collision angle results in totally different behavior.

Could this be a SceneKit bug or is this actually the expected behavior?

1

1 Answers

3
votes

The SCNRenderer runs the physics simulation at every frame, calling the renderer(_:didSimulatePhysicsAtTime:) method of its SCNSceneRendererDelegate.

During the course of the crossing of the two spheres, there will be several frames rendered and each time the physics simulation is run, the collision will be detected.

This is expected. It's up to you to process the collision and put the two spheres in a state where they don't collide anymore. For example, in a game, once a projectile hits a player, it disappears. The collision is processed and therefore does not fire anymore.


I used your code in the following way with XCode Version 8.0 beta 5 (8S193k) running as an OS X app. I see the following trace in the console. The begin and end methods are called exactly once !

(1) Node A began contact with Node B
(1) Node A ended contact with Node B


import SceneKit
import QuartzCore

class GameViewController: NSViewController, SCNSceneRendererDelegate, SCNPhysicsContactDelegate {

    @IBOutlet weak var gameView: GameView!

    // MARK: Properties

    var cameraNode: SCNNode!

    var nodeA: SCNNode!
    var nodeB: SCNNode!

    var countBeginnings: Int = 0
    var countEndings: Int = 0

    // MARK: Initialization

    override func awakeFromNib(){
        super.awakeFromNib()

        // create a new scene
        let scene = SCNScene(named: "art.scnassets/scene.scn")!

        // create and add a camera to the scene
        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        scene.rootNode.addChildNode(cameraNode)

        // place the camera
        cameraNode.position = SCNVector3(x: 0, y: 0, z: 15)

        // create and add a light to the scene
        let lightNode = SCNNode()
        lightNode.light = SCNLight()
        lightNode.light!.type = SCNLightTypeOmni
        lightNode.position = SCNVector3(x: 0, y: 10, z: 10)
        scene.rootNode.addChildNode(lightNode)

        // create and add an ambient light to the scene
        let ambientLightNode = SCNNode()
        ambientLightNode.light = SCNLight()
        ambientLightNode.light!.type = SCNLightTypeAmbient
        ambientLightNode.light!.color = NSColor.darkGray
        scene.rootNode.addChildNode(ambientLightNode)

        // set the scene to the view
        self.gameView!.scene = scene

        // allows the user to manipulate the camera
        self.gameView!.allowsCameraControl = true

        // show statistics such as fps and timing information
        self.gameView!.showsStatistics = true

        // configure the view
        self.gameView!.backgroundColor = NSColor.black

        self.gameView!.delegate = self

        setupScene()
        setupNodes()
    }

    func setupScene() {

        // assign self as contactDelegate to handle collisions
        self.gameView!.scene?.physicsWorld.contactDelegate = self

        // create the camera and position it at origin
        cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3Zero
        self.gameView!.scene?.rootNode.addChildNode(cameraNode)

        // tell scnView to update every frame
        self.gameView.isPlaying = true
    }

    func setupNodes() {
        // create two spheres with physicsBodies, one inside the other
        nodeA = SCNNode()
        nodeA.name = "Node A"
        nodeA.geometry = SCNSphere(radius: 1.0)
        nodeA.geometry!.firstMaterial?.diffuse.contents = NSColor.yellow.withAlphaComponent(0.6)
        nodeA.position = SCNVector3(x: 0.0, y: -0.8, z: -10.0)
        nodeA.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: nodeA.geometry!, options: nil))
        self.gameView!.scene?.rootNode.addChildNode(nodeA)

        nodeB = SCNNode()
        nodeB.name = "Node B"
        nodeB.geometry = SCNSphere(radius: 0.5)
        nodeB.geometry!.firstMaterial?.diffuse.contents = NSColor.red
        nodeB.position = SCNVector3(x: -2.0, y: 0.0, z: -10.0)
        nodeB.physicsBody = SCNPhysicsBody(type: .kinematic, shape: SCNPhysicsShape(geometry: nodeB.geometry!, options: nil))
        self.gameView!.scene?.rootNode.addChildNode(nodeB)

        // node A can collide with node B but not the other way around
        nodeA.physicsBody!.categoryBitMask = 2
        nodeB.physicsBody!.categoryBitMask = 1
        nodeA.physicsBody!.contactTestBitMask = 1
    }

    // MARK: SCNPhysicsContactDelegate

    func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
        countBeginnings += 1
        print("(" + String(countBeginnings) + ") " + contact.nodeA.name! + " began contact with " + contact.nodeB.name!)
    }
    func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) {
        countEndings += 1
        print("(" + String(countEndings) + ") " + contact.nodeA.name! + " ended contact with " + contact.nodeB.name!)
    }

    // MARK: SCNSceneRendererDelegate

    var frameNumber = 0
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        nodeB.position.x += 0.01
        nodeB.position.y -= 0.01
    }
}