Dynamically load Collada files in SceneKit at runtime

The problem

For an upcoming project, a client asked me if I could build a prototype which could load Collada files at runtime.  The flow has to be like this

  • User downloads Collada zip file while using the app (e.g. in-app purchase)
  • Collada file gets unzipped
  • Show the downloaded Collada file in the app

I started looking a possible 3D engines which I could use like Unity, but then I remembered Apple has released the SceneKit SDK which allows pretty high-level access, but with excellent performance.

I looked at the documentation and at first it looked quite simple to do.  Sadly Apple does some magic behind the scenes with the exported Collada file to compress and optimize it.

If you try to use a downloaded .dae file and load it at runtime, you’ll get the following error.

scenekit COLLADA files are not supported on this platform.

Luckily I could find out which tools are used and now it seems possible to do.

You can find the complete project with all the files and converter scripts on Github.

A solution

So we go with SceneKit.

Create a new Xcode project, choose  the Game template. Choose for SceneKit as Game Technology.

First you’ll need to create a Collada file (.dae) with a 3D program, like Blender (free), Maya, etc … (In the git repository you can find a test file).  You can drop this file in the art.scnassets directory and you are done … except I want to load this file at runtime, so it is not accessible at compile-time.  That’s a totally different story, as Apple applies some magic to the scnassets directory to optimize the included Collada files.

So how can we fixed this? I looked at the build logs what exactly happens to the scnassets folder to figure out what kind of magic is happening.  It seems Apple calls a script named copySceneKitAssets, this script calls eventually scntool  to optimize the included .dae files.

Alright with this knowledge I extracted these two scripts out of their directory (/Applications/Xcode/Contents/Developer/usr/bin) and placed them somewhere on my system.

So every time my client creates a new 3D model that should be available for sale via IAP, he should do the following.

  • Create a product.scnassets folder
  • Run the script
./copySceneKitAssets product-1.scnassets/ -o product-1-optimized.scnassets
  • Zip the result
  • Put it on the server

Now we can download the zip file with the following code in our GameViewController.m (I use AFNetworking and SSZipArchive to do the heavy lifting).

In viewDidLoad: I added the following code, it just downloads the zip file from our server and unzips it.

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self downloadZip];
}

- (void)downloadZip {
    
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    AFURLSessionManager *manager = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration];
    
    NSURL *URL = [NSURL URLWithString:@"http://www.the-nerd.be/product-1-optimized.scnassets.zip"];
    NSURLRequest *request = [NSURLRequest requestWithURL:URL];
    
    NSURLSessionDownloadTask *downloadTask = [manager downloadTaskWithRequest:request progress:nil destination:^NSURL *(NSURL *targetPath, NSURLResponse *response) {
        NSURL *documentsDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
        return [documentsDirectoryURL URLByAppendingPathComponent:[response suggestedFilename]];
    } completionHandler:^(NSURLResponse *response, NSURL *filePath, NSError *error) {
        NSLog(@"File downloaded to: %@", filePath);
        
        // Unzip the archive
        NSArray  *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
        NSString *documentsDirectory = [paths objectAtIndex:0];
        NSString *inputPath = [documentsDirectory stringByAppendingPathComponent:@"/product-1-optimized.scnassets.zip"];
        
        NSError *zipError = nil;
        
        [SSZipArchive unzipFileAtPath:inputPath toDestination:documentsDirectory overwrite:YES password:nil error:&zipError];
        
        if( zipError ){
            NSLog(@"[GameVC] Something went wrong while unzipping: %@", zipError.debugDescription);
        }else {
            NSLog(@"[GameVC] Archive unzipped successfully");
            [self startScene];
        }
        
    }];
    [downloadTask resume];
    
}

When everything is unzipped we need to create our scene and add the downloaded asset. To do this we need the help of the SCNSceneSource class. Luckily it’s really easy to accomplish this. Just get the path to our scnassets folder and feed it to the initializer.

// Load the downloaded scene
NSURL *documentsDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSDocumentDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil];
    documentsDirectoryURL = [documentsDirectoryURL URLByAppendingPathComponent:@"product-1-optimized.scnassets/cube.dae"];
    
SCNSceneSource *sceneSource = [SCNSceneSource sceneSourceWithURL:documentsDirectoryURL options:nil];

After this we can just get a reference to the Cube node in our Collada file with the following line of code.  The identifier is the name you gave the 3D object in your 3D program. In my case it was ‘Cube’.

// Get reference to the cube node
SCNNode *theCube = [sceneSource entryWithIdentifier:@"Cube" withClass:[SCNNode class]];

Last bit is create an empty scene, set up a camera and add a light.

// Create a new scene
SCNScene *scene = [SCNScene scene];
    
// create and add a camera to the scene
SCNNode *cameraNode = [SCNNode node];
cameraNode.camera = [SCNCamera camera];
[scene.rootNode addChildNode:cameraNode];
    
// place the camera
cameraNode.position = SCNVector3Make(0, 0, 15);
    
// create and add a light to the scene
SCNNode *lightNode = [SCNNode node];
lightNode.light = [SCNLight light];
lightNode.light.type = SCNLightTypeOmni;
lightNode.position = SCNVector3Make(0, 10, 10);
[scene.rootNode addChildNode:lightNode];
    
// create and add an ambient light to the scene
SCNNode *ambientLightNode = [SCNNode node];
ambientLightNode.light = [SCNLight light];
ambientLightNode.light.type = SCNLightTypeAmbient;
ambientLightNode.light.color = [UIColor darkGrayColor];
[scene.rootNode addChildNode:ambientLightNode];

Final step … add the cube to the scene with this line of code.

// Add our cube to the scene
[scene.rootNode addChildNode:theCube];

And lets put the scene in a SCNView object, so we can show it.

// retrieve the SCNView
SCNView *scnView = (SCNView *)self.view;

// set the scene to the view
scnView.scene = scene;

// allows the user to manipulate the camera
scnView.allowsCameraControl = YES;

// show statistics such as fps and timing information
scnView.showsStatistics = YES;

// configure the view
scnView.backgroundColor = [UIColor blackColor];

That’s it … run the code and you will see that

  1. The Collada zip file gets downloaded from our server
  2. The file gets unzipped
  3. The resulting Collada file gets loaded into memory
  4. The Collada file gets shown!