Sunday, June 3, 2012

OS X Game Development Using Cocoa NSOpenGLView

Every once in a year or so I get an uncontrollable urge to write a game. I like classic arcade games, the kind where you have a spaceship and zap alien monsters. Traditionally you could draw sprites by copying tiles of pixels to screen memory, nowadays we use OpenGL. If you want to do this on the Mac, use Cocoa and its NSOpenGLView class.

Coming from the Linux world, I became somewhat attached to the SDL library. SDL is great but under OS X, it doesn't feel native and the end product isn't as good. The differences are in little things like the application menu, app icon, and support for different keyboard layouts. So let's just use OS X's native Cocoa layer and make a great game.

First off, Cocoa is an API for the Objective-C programming language. Since Objective-C can be mixed with plain good old C, we can write the entire game in C and have the display be a front end written in Objective-C and using NSOpenGLView.
If you google around for "NSOpenGLView tutorial" or "mac opengl" you'll find a lot of old code and horror stories. Using OpenGL on the Mac used to be much harder than it is today. Let's get started.

Making an OpenGL capable view
Like always in Cocoa, create your own new view class derived from an already existing view class:
@interface GameView : NSOpenGLView

@end
In Interface Builder (IB) draw an NSOpenGLView into the main window and change its class to the GameView class that we just made. Be sure to enable double buffering in the Attributes Inspector.
In GameView.m, implement this stretch of code (see below for explanation):
@implementation GameView

- (void)prepareOpenGL {
    init_gl();

    // this sets swap interval for double buffering
    GLint swapInt = 1;
    [[self openGLContext] setValues:&swapInt forParameter:NSOpenGLCPSwapInterval];
   
    // this enables alpha in the frame buffer (commented now)
//  GLint opaque = 0;
//  [[self openGLContext] setValues:&opaque forParameter:NSOpenGLCPSurfaceOpacity];
}

- (void)drawRect:(NSRect)dirtyRect {
    glClear(GL_COLOR_BUFFER_BIT);   
    glLoadIdentity();
  
    draw_screen();
   
//  glFlush();
// the correct way to do double buffering on Mac is this:
    [[self openGLContext] flushBuffer];
   
    int err;
    if ((err = glGetError()) != 0)
        NSLog(@"glGetError(): %d", err);
}

- (void)reshape {
//  NSLog(@"view reshape {%.02f %.02f}", [self frame].size.width, [self frame].size.height);
   
    // window resize; width and height are in pixel coordinates
    // but they are floats
    float screen_w = [self frame].size.width;
    float screen_h = [self frame].size.height;

    // here I cast floats to ints; most systems use integer coordinate systems
    screen_resize((int)screen_w, (int)screen_h);
}

- (BOOL)acceptsFirstResponder {
    return YES;
}

- (void)keyDown:(NSEvent *)theEvent {
    if ([theEvent isARepeat])
        return;
   
    NSString *str = [theEvent charactersIgnoringModifiers];
    unichar c = [str characterAtIndex:0];
   
    if (c < ' ' || c > '~')     // only ASCII please
        c = 0;
   
    key_down([theEvent keyCode], c);
}

- (void)keyUp:(NSEvent *)theEvent {
    key_up([theEvent keyCode]);
}

@end
This code unites the Objective-C API with the standard C code. The pure C init_gl() function should set up the projection and modelview matrices and other OpenGL parameters just like before with SDL, GLUT, GLFW or any other library. Likewise, screen_resize() should call glViewport() to update OpenGL's viewport.

As you can see, some things are a little different on the Mac, like having to enable swapping for double buffering. If you don't do this, you won't see anything being displayed.
Also note the keyUp and keyDown event handlers. Remember the SDL event loop? This is hidden in Cocoa, already built-in. All you do is write the event handlers. You might also add mouse event handlers.

Frame rates and timings
Frankly, the way that screen redraws work under Cocoa was a little mind-bending for me in the beginning. With SDL you just set up a main loop, redraw the screen, do game mechanics, and call SDL_Delay() to sleep some milliseconds for getting the frame rate right. In Cocoa, you can not have a main loop because it would interfere with the invisible (it's hidden!) main event loop. So to get a frame rate going you have to set up a timer that periodically updates the screen. But rather than just that, the frame rate timer has to drive the entire game: do animations, do game mechanics, and finally tell Cocoa to redraw the screen.

To get a super consistent frame rate in SDL I would actually take into account the time passed since the last loop. In Cocoa, the timer fires just like you specified (its accuracy is good enough) but rather than having a frame rate, it's the rate at which you drive the game. The end result is practically the same. You can't really control the frame rate anyway because OS X decides what happens on the display — and it does a nice job too, no need to worry about flicker or tearing whatsoever.

Setting up a timer sounds easy enough, but there is more to it. When the window is minimized or goes out of focus, you will want to stop the timer to freeze the game. In Cocoa you can catch these window events by implementing the NSWindowDelegate protocol. So by adding the timer and the protocol to the GameView class, we get exactly what we want.

In GameView.h change the class declaration to:
@interface GameView : NSOpenGLView<NSWindowDelegate>
In GameView.m add this code:
static NSTimer *timer = nil;

- (void)windowDidResignMain:(NSNotification *)notification {
//    NSLog(@"window did resign main");
    [timer invalidate];
   
    game_deactivate();      // freeze, pause
    [self setNeedsDisplay:YES];
}

- (void)windowDidBecomeMain:(NSNotification *)notification {
//    NSLog(@"window did become main");
   
    game_activate();
    [self setNeedsDisplay:YES];
   
    timer = [NSTimer timerWithTimeInterval:FRAME_INTERVAL
               target:self
               selector:@selector(timerEvent:)
               userInfo:nil
               repeats:YES];
   
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)timerEvent:(NSTimer *)t {
    run_game();
    [self setNeedsDisplay:YES];
}
This code ties an NSTimer to a pure C function run_game()that will do a single run of the game "loop". Next we ask Cocoa to redisplay the view by issuing setNeedsDisplay:YES. Cocoa will pick this up and send a -drawRect: message, which will call draw_screen(). Realize that this code redraws the screen on every frame, which is good for arcade action games.
When the window is minimized or goes out of focus, the game is put into a paused state and the timer is stopped. We ask for one more redraw so that we can show a nice pause screen.

To be able to detect when the window goes out of focus, we need to tell Cocoa that the GameView should receive windowing events, otherwise it doesn't see them. Put this in AppDelegate.h:
#import "GameView.h"

@property (assign) IBOutlet GameView *gameview;
Put this in AppDelegate.m:
@synthesize gameview;

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
    // Insert code here to initialize your application

    [[self window] setDelegate:[self gameview]];
}
In IB, connect the GameView to the gameview outlet we created. (Or use the Assistent Editor). Now when the window goes out of focus, the application will send that event to the view, and our timer will stop and the game will enter paused state.

One more thing
I like it when games can toggle between windowed and full screen mode. Especially for small, simple arcade games it adds to the experience. In code, toggling screen mode is always a bit of a hassle. Well, it was until OS X Lion added support for full screen apps. In XCode 4 you can select the application window in IB and select "Primary Window" for Full Screen in the Attributes Inspector — and that is all. If your screen_resize() function is working properly, it just works.

Concluding
As always, it takes some effort to get Cocoa going. But when it does, you get some really nice things in return. Having the game integrate with OS X adds so much value. It's simple things like having a decent About dialog, properly supporting the native keyboard layout, having the standard full screen apps button in the window title bar. Suddenly the game looks and feels like a native OS X app.
This concludes this tutorial on OpenGL under OS X. Read on for two more musings in case you haven't had enough yet.

Notes on portability
Cocoa is OS X-only so we break portability with other platforms by choosing NSOpenGLView. However, since the entire game is written in C and only the front end is "Mac native" you only have to port the front end to other platforms.
You might also choose to write the entire game in Objective-C. It's a nice language for developing games, but do note that today Apple's Objective-C 2.0 does not port nicely to other platforms — other than iOS (and even Cocoa apps do not port nicely to iOS). Sticking with C or C++ ensures your core game code is easily ported.

Why OpenGL?
In the earlier days of computing, there was no such thing as a graphics processor. There was a video chip that put a region of memory (the screen pixel buffer) onto the monitor display at the right refresh rate. Drawing sprites was done by copying tiles of pixels into the screen memory region. That was OK with screen resolutions like 320x200 or 320x240, but on today's screen resolutions you would run the CPU hot just by copying pixels. So nowadays we have hardware accelerated graphics and we use OpenGL to tell the video hardware what to display. The OpenGL library is like the standard C library for displaying graphics. Moreover, it's tailored to the graphics processing pipeline that is built into the hardware. So even if you're just doing 2D graphics, please use OpenGL. It's hardware accelerated and your games won't melt the CPU because it's the video hardware that is doing the hard work.