Which thread should I call SceneKit's node manipulation APIs?

4 days ago 10
ARTICLE AD BOX

In Apple's SceneKit sample code (download here: https://developer.apple.com/library/archive/samplecode/scenekit-2017/Introduction/Intro.html#//apple_ref/doc/uid/TP40017656), we have:

@implementation AAPLGameViewController - (void)viewDidLoad { self.gameController = [[AAPLGameController alloc] initWithSCNView:self.gameView]; } @end @implementation AAPLGameController - (instancetype)initWithSCNView:(SCNView *)scnView { [self addFriends:3]; [self setupPlatforms]; } - (void)addFriends:(NSUInteger)count { friend.position = ... [friends addChildNode:friend]; } - (void)setupPlatforms { SCNAction *moveAction = [SCNAction moveBy:SCNVector3Make(alternate * PLATFORM_MOVE_OFFSET, 0, 0) duration:1/PLATFORM_MOVE_SPEED]; moveAction.timingMode = SCNActionTimingModeEaseInEaseOut; [node runAction:[SCNAction repeatActionForever:[SCNAction sequence:@[moveAction, moveAction.reversedAction]]]]; } @end

We know that viewDidLoad must be in main thread, meaning that initWithSCNView is in main, so setupPlatforms and addFriends is in main.

So Apple actually manipulates node (e.g. sets positions, add children, and call runAction) all in main thread.

However, when I tested my own game by running a sequence of scale action and then removeFromParentNode action, and I set breakpoint in my node's corresponding functions, and confirmed they are actually called in the background render thread.

final class ToyUI: SCNNode { override func removeFromParentNode() { super.removeFromParentNode() } override var scale: SCNVector3 { get { return super.scale } set { super.scale = newValue } } }

Isn't this gonna cause race condition? These properties and APIs are accessed from both main thread and the background render thread?

I have seen SceneKit - Threads - What to do on which thread? but the top voted answer seems to suggest that we should manipulate nodes on this background render thread, which contradicts with apple's sample code.

For some additional context why I am asking this question:

A year ago I got a very rare crash reported on Firebase that I am not able to reproduce:

Crashed: com.apple.scenekit.renderingQueue.LIBGame.MySCNView0x16b47e300 0 libobjc.A.dylib 0x3020 objc_msgSend + 32 1 SceneKit 0x17def4 -[SCNNode(SIMD) setSimdScale:] + 56 2 SceneKit 0x277b08 SCNCActionScale::cpp_updateWithTargetForTime(SCNNode*, double) + 180 3 SceneKit 0x103404 SCNActionApply + 136 4 SceneKit 0x1a01cc _applyActions + 240 5 CoreFoundation 0x1a0a18 -[__NSFrozenDictionaryM __apply:context:] + 128 6 SceneKit 0x19ffec C3DAnimationManagerApplyActions + 116 7 SceneKit 0x18a058 -[SCNRenderer _update:] + 572 8 SceneKit 0x18c1c8 -[SCNRenderer _drawSceneWithNewRenderer:] + 156 9 SceneKit 0x18c6e8 -[SCNRenderer _drawScene:] + 44 10 SceneKit 0x18ca2c -[SCNRenderer _drawAtTime:] + 536 11 SceneKit 0x22390c -[SCNView _drawAtTime:] + 428 12 SceneKit 0xdcd00 __83-[NSObject(SCN_DisplayLinkExtensions) SCN_setupDisplayLinkWithQueue:screen:policy:]_block_invoke + 48 13 SceneKit 0x35b134 -[SCNDisplayLink _displayLinkCallbackReturningImmediately] + 128 14 libdispatch.dylib 0x1b7fc _dispatch_client_callout + 16 15 libdispatch.dylib 0x6664 _dispatch_continuation_pop + 596 16 libdispatch.dylib 0x19538 _dispatch_source_latch_and_call + 396 17 libdispatch.dylib 0x1820c _dispatch_source_invoke + 844 18 libdispatch.dylib 0xa2d0 _dispatch_lane_serial_drain + 332 19 libdispatch.dylib 0xaf44 _dispatch_lane_invoke + 388 20 libdispatch.dylib 0x153ec _dispatch_root_queue_drain_deferred_wlh + 292 21 libdispatch.dylib 0x14ce4 _dispatch_workloop_worker_thread + 692 22 libsystem_pthread.dylib 0x13b8 _pthread_wqthread + 292 23 libsystem_pthread.dylib 0x8c0 start_wqthread + 8

This seems to say that scale is called on the node that is already gone. My related code is here:

let sequence: [SCNAction?] = [ .wait(duration: afterDelay), moveUp, .wait(duration: duration_delay_between_moves), moveToCenterToy, scaleToZero, .removeFromParentNode() ] toy.runAction(.sequence(sequence.compactMap { $0 }))

I suspected it was a internal race condition bug in SceneKit, so I added a delay between scale and removeFromParentNode actions:

let sequence: [SCNAction?] = [ .wait(duration: afterDelay), moveUp, .wait(duration: duration_delay_between_moves), moveToCenterToy, scaleToZero, // ADDED THIS .wait(duration: duration_gap_before_removing_self), .removeFromParentNode() ] toy.runAction(.sequence(sequence.compactMap { $0 }))

Then this greatly reduced the crash on iOS 18 to almost nothing, so I moved on. However, since iOS 26 was released, I started to have increased number of crashes on iOS 26 again.

I have tried increasing the delay (duration_gap_before_removing_self), but it didn't help.

I am considering replacing SCNAction.removeFromParentNode with a custom callback action, so that .removeFromParentNode() is called in main:

let myCustomRemove = SCNAction.run { node in Task { @MainActor in node.removeFromParentNode() } }

But I am not sure if it will work.

Read Entire Article