Robert
Robert

Reputation: 6810

How to add SCNNodes without blocking main thread?

I'm creating and adding a large number of SCNNodes to a SceneKit scene, which causes the app to freeze for a second or two.

I thought I could fix this by putting all the action in a background thread using DispatchQueue.global(qos: .background).async(), but no dice. It behaves exactly the same.

I saw this answer and put the nodes through SCNView.prepare() before adding them, hoping it would slow down the background thread and prevent blocking. It didn't.

Here's a test function that reproduces the problem:

func spawnNodesInBackground() {
    // put all the action in a background thread
    DispatchQueue.global(qos: .background).async {
        var nodes = [SCNNode]()
        for i in 0...5000 {
            // create a simple SCNNode
            let node = SCNNode()
            node.position = SCNVector3(i, i, i)
            let geometry = SCNSphere(radius: 1)
            geometry.firstMaterial?.diffuse.contents = UIColor.white.cgColor
            node.geometry = geometry
            nodes.append(node)
        }
        // run the nodes through prepare()
        self.mySCNView.prepare(nodes, completionHandler: { (Bool) in
            // nodes are prepared, add them to scene
            for node in nodes {
                self.myRootNode.addChildNode(node)
            }
        })
    }
}

When I call spawnNodesInBackground() I expect the scene to continue rendering normally (perhaps at a reduced frame rate) while new nodes are added at whatever pace the CPU is comfortable with. Instead the app freezes completely for a second or two, then all the new nodes appear at once.

Why is this happening, and how can I add a large number of nodes without blocking the main thread?

Upvotes: 12

Views: 5324

Answers (4)

Hope
Hope

Reputation: 2326

Add all of them when application started but position them where camera dont see. When you need them change their position where they should be.

Upvotes: 0

FrostyL
FrostyL

Reputation: 811

I have an asteroid simulation with 10000 nodes and ran into this issue myself. What worked for me was creating the container node, then passing it to a background process to fill it with child nodes.

That background process uses an SCNAction on that container node to add each of the generated asteroids to the container node.

let action = runBlock { 
    Container in
    // generate nodes
    /// then For each node in generatedNodes
    Container.addChildNode(node)
}

I also used a shared level of detail node with an uneven sided block as its geometry so that the scene can draw those nodes in a single pass.

I also pre-generate 50 asteroid shapes that get random transformations applied during the background generation process. That process simply has to grab at random a pregen block apply a random simd transformation then stored for adding scene later.

I’m considering using a pyramid for the LOD but the 5 x 10 x 15 block works for my purpose. Also this method can be easily throttled to only add a set amount of blocks at a time by creating and passing multiple actions to the node. Initially I passed each node as an action but this way works too.

Showing the entire field of 10000 still affects the FPS slightly by 10 a 20 FPS but At that point the container nodes own LOD comes into effect showing a single ring.

Upvotes: 1

Robert
Robert

Reputation: 6810

I don't think this problem is solvable using the DispatchQueue. If I substitute some other task instead of creating SCNNodes it works as expected, so I think the problem is related to SceneKit.

The answers to this question suggest that SceneKit has its own private background thread that it batches all changes to. So regardless of what thread I use to create my SCNNodes, they all end up in the same queue in the same thread as the render loop.

The ugly workaround I'm using is to add the nodes a few at a time in SceneKit's delegated renderer(_:updateAtTime:) method until they're all done.

Upvotes: 9

Hal Mueller
Hal Mueller

Reputation: 7646

I poked around on this and didn't solve the freeze (I did reduce it a bit).

I expect that prepare() is going to exacerbate the freeze, not reduce it, because it's going to load all resources into the GPU immediately, instead of letting them be lazily loaded. I don't think you need to call prepare() from a background thread, because the doc says it already uses a background thread. But creating the nodes on a background thread is a good move.

I did see pretty good performance improvement by moving the geometry outside the loop, and by using a temporary parent node (which is then cloned), so that there's only one call to add a new child to the scene's root node. I also reduced the sphere's segment count to 10 (from the default of 48).

I started with the spinning spaceship sample project, and triggered the addition of the spheres from the tap gesture. Before my changes, I saw 11 fps, 7410 draw calls per frame, 8.18M triangles. After moving the geometry out of the loop and flattening the sphere tree, I hit 60 fps, with only 3 draw calls per frame and 1.67M triangles (iPhone 6s).

Do you need to build these objects at run time? You could build this scene once, archive it, and then embed it as an asset. Depending on the effect you want to achieve, you might also consider using SCNSceneRenderer's present(_:with:incomingPointOfView:transition:completionHandler) to replace the entire scene at once.

func spawnNodesInBackgroundClone() {
    print(Date(), "starting")
    DispatchQueue.global(qos: .background).async {
        let tempParentNode = SCNNode()
        tempParentNode.name = "spheres"
        let geometry = SCNSphere(radius: 0.4)
        geometry.segmentCount = 10
        geometry.firstMaterial?.diffuse.contents = UIColor.green.cgColor
        for x in -10...10 {
            for y in -10...10 {
                for z in 0...20 {
                    let node = SCNNode()
                    node.position = SCNVector3(x, y, -z)
                    node.geometry = geometry
                    tempParentNode.addChildNode(node)
                }
            }
        }
        print(Date(), "cloning")
        let scnView = self.view as! SCNView
        let cloneNode = tempParentNode.flattenedClone()
        print(Date(), "adding")
        DispatchQueue.main.async {
            print(Date(), "main queue")
            print(Date(), "prepare()")
            scnView.prepare([cloneNode], completionHandler: { (Bool) in
                scnView.scene?.rootNode.addChildNode(cloneNode)
                print(Date(), "added")
            })
            // only do this once, on the simulator
            // let sceneData = NSKeyedArchiver.archivedData(withRootObject: scnView.scene!)
            // try! sceneData.write(to: URL(fileURLWithPath: "/Users/hal/scene.scn"))
            print(Date(), "queued")
        }
    }
}

Upvotes: 2

Related Questions