Asynchronous texture loading on iOS + GCD

Here's what we're going to cover:

  1. I will briefly describe GLKit and how you can use it to load textures.
  2. We will then cover how to asynchronously load textures.
  3. I'll explain a minor caveat regarding memory.
  4. I will demonstrate how to use dispatch groups for completion notification.

Cowboys can skip to the complete code listing.

GLKit

If you're doing OpenGL on iOS and avoided using GLKit, I highly suggest you look into it. It has a myriad of useful helper classes and functions (GLKVectorN, GLKMatrixN, GLKMatrix4Translate(...)) and is well worth exploring. I wasted hours converting C++ GLU code for iOS before I realised that the GLK Math Utilities includes GLKMathProject and GLKMathUnproject, for converting between 2D and 3D points. Definitely have a quick browse of the documentation.

GLKit provides a kind of fake fixed pipeline if you want to write your GL in the older style or you are free to pick and mix what you need to achieve your own fully programmable pipeline.

GLKTextureInfo & GLKTextureLoader

One of the provided helpers is GLKTextureInfo and its sibling GLKTextureLoader. GLKTextureLoader lets you easily load textures in many image formats from disk.

Normally you would be using GLKTextureLoader like this:

NSError *error;
GLKTextureInfo *texture;
NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft:@YES};
texture = [GLKTextureLoader textureWithContentsOfFile:imagePath
                                              options:options
                                                error:&error];
if(error){
  // give up
}
NSLog(@"Texture loaded, name: %d, WxH: %d x %d",
      texture.name,
      texture.width,
      texture.height);
glBindTexture(GL_TEXTURE_2D, texture.name);

Doing Things Asynchronously

It's very simple to load texutres in a background thread with GLKTextureLoader. Instead of using the class method, you allocate a GLKTextureLoader instance and pass in a EAGLShareGroup. The sharegroup allows different EAGLContext to share textures and buffers.

//
// Imagine you have this in your .h
//

@property (strong) GLKTextureLoader *asyncTextureLoader;
@property (strong) GLKTextureInfo *hugeTexture;
@property (strong) EAGLContext *context;


//
// somewhere in your .m initialization code
//

// create GL context
self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];

// check errors, etc ...

// create texture loader and give it the context share group.
self.asyncTextureLoader = [GLKTextureLoader alloc] initWithSharegroup:self.context.sharegroup]

//
// Later, when you need to load your texures
//

// same as before
NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft:@YES};
// get a GCD queue to run the load on
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

void (^complete)(GLKTextureInfo*, NSError*) = ^(GLKTextureInfo *texture,
                                                NSError *e){
  //
  // NOTE: the GLKTextureLoader documentation incorrectly states that the 
  //       completion block is passed a GLTextureName and an NSError when
  //       infact its passed a GLKTextureInfo instance and an NSError.
  //       Just let xcodes autocompletion guide you to the right signature.
  //
  if(e){
    // give up
    return;
  }

  // set your property
  self.hugeTexture = texture;

  // (detecting that you're ready to bind and draw is left as 
  // an exercise to the reader, the easiest way would be to
  // check self.hugeTexture == nil in your update method)
};
// load texture in queue and pass in completion block
[self.asyncTextureLoader textureWithContentsOfFile:@"my_texture_path.png"
                                           options:options
                                             queue:queue
                                 completionHandler:complete];

Fixing leaks

There is one perhaps not so obvious memory leak with this code, if you call it multiple times.

GLKTextureInfo doesn't own any memory beyond a few GLuint's. When you re-assign self.hugeTexture, the GLKTextureInfo gets deallocated but the memory used for the pixels is not. That memory is owned by OpenGL and you must call glDeleteTextures to free it.

// get the texture gl name
GLuint name = self.hugeTexture.name;
// delete texture from opengl
glDeleteTextures(1, &name);
// set texture info to nil (or your new texture, etc)
self.hugeTexture = nil;

You might think to put this in your completion block and then you're home free but you will still be leaking memory. The details are not 100% clear to me, but from what I know:

  1. Every iOS thread requires its own EAGLContext.
  2. Your completion handler is run on the queue you passed in. (Try logging dispatch_queue_get_label(dispatch_get_current_queue()) in a few places to see this.)

Since we are not executing the async load on the main queue (what would be the point of that?), our completion handler is not run on the main queue and does not have access to the correct context.

There are two solutions to this:

  1. Delete your texture in the main queue, before you run your async texture load
  2. Force the completion hander to run on the main queue

The first option is easy, you just call the glDeleteTextures code above, before calling textureWithContentsOfFile.

To perform the second option, you'll have to modify your completion block slightly, calling your delete code in a dispatch block on the main queue. See the complete code listing for an example.

Complete Code Listing

//
// Imagine you have this in your .h
//

@property (strong) GLKTextureLoader *asyncTextureLoader;
@property (strong) GLKTextureInfo *hugeTexture;
@property (strong) EAGLContext *context;


//
// somewhere in your initialization code
//

// create GL context
self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];

// check errors, etc ...

// create texture loader and give it the context share group.
self.asyncTextureLoader = [GLKTextureLoader alloc] initWithSharegroup:self.context.sharegroup]

//
// Later, when you need to load your texures
//

NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft:@YES};
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
void (^complete)(GLKTextureInfo*, NSError*) = ^(GLKTextureInfo *texture,
                                                NSError *e){
  if(e){
    // give up
    return;
  }
  // run our actual completion code on the main queue
  // so the glDeleteTextures call works
  dispatch_sync(dispatch_get_main_queue(), ^{
    // delete texture
    GLuint name = self.hugeTexture.name;
    glDeleteTextures(1, &name);
    // assign loaded texture
    self.hugeTexture = texture;
  });
};
// load texture in queue and pass in completion block
[self.asyncTextureLoader textureWithContentsOfFile:@"my_texture_path.png"
                                           options:options
                                             queue:queue
                                 completionHandler:complete];

Knowing When Multiple Loads Finish

As an aside, you can use GCD dispatch groups to run a completion handler after multiple loads have completed. The process is:

  1. Create a dispatch_group_t with dispatch_group_create()
  2. For each async task you are doing, enter the group with dispatch_group_enter(group)
  3. When your async task is done, leave the group with dispatch_group_leave(group)
  4. Register for group completion with dispatch_group_notify(...)

Here's a contrived example.

//
// imagine we have a 'please wait, loading' view showing and we want
// to hide it after these textures are loaded
//
self.loadingView.hidden = NO;

// files to load
NSArray *files = @[@"my_texture_1.png",
                  @"my_texture_2.png",
                  @"my_texture_3.png"];
// resulting textures will be saved here
NSMutableArray *textures;

// setup defaults for GLKTextureLoader
NSDictionary *options = @{GLKTextureLoaderOriginBottomLeft:@YES};
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

// #1
// create a dispatch group, which we will add our tasks too.
// the group will let us know when all the tasks are complete.
// you can think of a group sort of like a JS promise.
dispatch_group_t textureLoadGroup = dispatch_group_create();

// load each of our textures async
for(NSString *file in files){
    // define our completion hander
    // remember, this is called AFTER the texture has loaded
    void (^complete)(GLKTextureInfo*, NSError*) = ^(GLKTextureInfo *texture,
                                                    NSError *e){
        NSLog(@"Loaded texture: %@", file);
        [textures addObject:texture];
        // #3
        // Leave the dispatch group once we're finished
        dispatch_group_leave(textureLoadGroup);
    };

    // #2
    // join the dispatch group before we start the task
    // this basically increments a counter on the group while
    // dispatch_group_leave will decrement that counter.
    dispatch_group_enter(textureLoadGroup);

    // load texture in queue and pass in completion block
    [self.asyncTextureLoader textureWithContentsOfFile:file
                                               options:options
                                                 queue:queue
                                     completionHandler:complete];
}

// #4
// set a block to be run when the group completes 
// (when everyone who entered has left)
// we'll run this notification block on the main queue
dispatch_group_notify(textureLoadGroup, dispatch_get_main_queue(), ^{
    NSLog(@"All textures are loaded.");
    for(GLKTextureInfo *t in textures){
        NSLog(@"Texture: %d, %d x %d", t.name, t.width, t.height);
    }
    // hide your loading view, etc
    self.loadingView.hidden = YES;
});

Finally, empty groups will fire instantly, which can allow you to tidy up some code that might be run irregularlly.

Imagine you had the following

- (void)resetLayout
{
  if(some_complex_view_visible){
    [UIView animateWithDuration:1.0f
                     animations:^{
                         // some complex animations to hide the complex view
                     }
                     completion:^(BOOL finished) {
                         // reset my other layout elements after the
                         // complex view is hidden
                         self.otherView.hidden = YES;
                     }];
  }
  else{
    // reset my other layout elements.
    self.otherView.hidden = YES;
  }
}

Obviously we have code duplication here and its a prime refactor target.

In the next example, self.otherView.hidden = YES; is run after the complex view animation is complete, or instantly if some_complex_view_visible == NO.

NB: the example could also be done by putting self.otherview.hidden = YES; in a block, then passing the block as the animation completion block and calling the block directly in the else clause. Hopefully you can see where the pattern might be applied in a more complex situation where blocks would get overly convoluted.

You should also note that resetLayout will return before the dispatch_group_notify block is executed. This pattern will not fit all problems but is useful to know. The example code is used only because UI animation code is familiar and easy to understand.

- (void)resetLayout
{
  // make group
  dispatch_group_t group = dispatch_group_create();
  if(some_complex_view_visible){
    // enter group
    dispatch_group_enter(group);
    [UIView animateWithDuration:1.0f
                     animations:^{
                         // some complex animations to hide the complex view
                     }
                     completion:^(BOOL finished) {
                        // leave group
                        dispatch_group_leave(group);
                     }];
  }
  // this is run instantly if the group is empty, else it is run after 
  // the group becomes empty.
  dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    // reset my other layout elements.
    self.otherView.hidden = YES;
  });
}

See Also

  1. For reference, on an iPad 3, iOS 6.1, with no other apps running, I was able to squeeze upto 650mb memory usage. I would get killed somewhere between that and the next asset set load (4x 2048x1536 images), so the limit is between 650-750mb.

Written 14th of May, 2013