Мобильные платформыСтатьи

Игры на OpenGL для iOS. Быстрый старт.

Внимание! Этот документ ещё не опубликован.

Автор:

Предисловие


Этот мануал написан для тех, кто хочет заниматься разработкой игры, не вникая в тонкости работы API фреймворков для iOS.
Информацию можно использовать для написания обвязки, прототипа или для понимания принципов работы.
Предполагается, что читатель знает, как создать проект приложения для iOS в Xcode и прилинковать фреймворки.
Так же стоит отметить, что использование ресурсов графического интефейса (*.xib или *.storyboard) не рассматривается.
Весь код актуален для iOS версии 5.0.0 и выше и всей линейки устройств, если не отмечено иначе. Модель менеджмента памяти - ARC.



OpenGL


Что бы не усложнять себе работу инициализацией OpenGL, настройкой графического контекста и прочей низкоуровневой рутиной, я использую GLKit. Он оборачивает инициализацию контекста, библиотеки, фрейм и рендербуферов. Так же предоставляет коллбэк для рисования в main loop’е. Тем самым, вся вышеупомянутая рутина сводится к написанию пары классов и нескольких строк кода. К коду и перейдём.

Нам необходимы два класса:

MainView.h

@interface MainView : GLKView
@end

Объявление и тело класса пока что оставим пустым. Позже он нам понадобится для обработки жестов.

MainViewController.h

@interface MainViewController : GLKViewController <GLKViewDelegate, GLKViewControllerDelegate>
@end

MainViewController.m
Для MainViewController необходимо описать следующие методы:

Конструктор.

- (id)init {
  if ((self = [super init])) {
    [self setDelegate:self];
    [self setPreferredFramesPerSecond:60]; // (1)
  }
  return self;
}

(1) По умолчанию установлено ограничение в 30 кадров в секунду.

Создание вьюхи. По документации [super loadView] вызывать необходимости нет.

- (void)loadView {
  EAGLContext *context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2]; (2)
  [EAGLContext setCurrentContext:context];
  self.view = [[MainView alloc] initWithFrame:[[UIScreen mainScreen] bounds] context:context];
  [(MainView *)self.view setContext:context];
  [(MainView *)self.view setDrawableColorFormat:GLKViewDrawableColorFormatRGBA8888]; (3)
  [(MainView *)self.view setDrawableDepthFormat:GLKViewDrawableDepthFormat24]; (4)
  [(MainView *)self.view setDelegate:self];
}

(2) Выбор версии графического API устанавливается флагами:


(3) Инициализация color render буфера. Для стареньких устройств с небольшим объёмом видеопамяти можно устанавливать флаг формата GLKViewDrawableColorFormatRGB565. С iOS 7 становится доступен новый флаг GLKViewDrawableColorFormatSRGBA8888 позволяющий оперировать графическими данными в формате sRGB.
(4) Инициализация буфера глубины. Его можно отключить, или установить 16 битным, что тоже очень хорошо подходит для стареньких устройств.

Так же есть возможность настроить stencil буфер (drawableStencilFormat) и multi sample antialiasing (drawableMultisample).

У MainView через свойства drawableWidth, drawableHeight в будущем получаем адекватный размер экрана в пикселях, без учёта ретины.

Событие об окончании загрузки вьюхи.

- (void)viewDidLoad {
  [super viewDidLoad];
  // код инициализации игры
}

Описываем методы протоколов:

GLKViewDelegate

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
  // Рендеринг
}

Вызывается указанное в preferredFramesPerSecond количество раз в секунду.

GLKViewControllerDelegate

- (void)glkViewControllerUpdate:(GLKViewController *)controller {
  // Апдейт логики
}

Вызывается каждый раз после вызова -glkView:drawInRect:.

Эти два метода просто разделяют графику и логику. В -glkViewControllerUpdate: я использую ограничитель на определённое количество раз в секунду.
Там где нужно узнать время, я использую CACurrentMediaTime(). В треде на stackoverflow есть обсуждение производительности нескольких методов получения времени и CACurrentMediaTime лидирует. Для игр это важно, ведь время определяется по несколько десятков раз за один проход игрового цикла.

Так выглядит таймер апдейтов:

- (void)glkViewControllerUpdate:(GLKViewController *)controller {
  static const CFAbsoluteTime kUPSDelta = 1.0 / 30.0; // 30 updates per second
  static const CFAbsoluteTime lastUpdateTime = 0.0;
  if (lastUpdateTime + kUPSDelta <= CACurrentMediaTime()) {
    // Апдейт логики
    lastUpdateTime += (lastUpdateTime == 0.0) ? CACurrentMediaTime() : kUPSDelta;
  }
}

По хорошему, lastUpdateTime должна быть свойством класса и инициализироваться текущим временем перед первым апдейтом, что бы не использовать тернарный оператор по 30 раз в секунду.
Суть этого таймера в том, что даже если игра подвисла на несколько секунд, впоследствии выполнятся все апдейты за время простоя.

Финальным аккордом в настройке OpenGL является установка MainViewController в качестве root для главного окна. Для этого в AppDelegate изменим метод -application:didFinishLaunchingWithOptions:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
  MainViewController *controller = [[MainViewController alloc] init];
  [self.window setRootViewController:controller];
  [self.window makeKeyAndVisible];
  return YES;
}




Обработка событий TouchPad'а

У UIKit'а есть несколько способов обработки событий. На мой взгляди самый простой это использование UIGestureRecognizer.
Этот API позволяет нам создать класс-обработчик события, который дёрнет наш коллбек в нужный момент и снабдит всеми необходимыми данными о событии.
Следующие события можно обрабатывать без слёз:

Рассмотрим пример обработки события тапа.
В конструкторе MainView создадим UITapGestureRecognizer:

- (id)initWithFrame:(CGRect)frame context:(EAGLContext *)context {
  if ((self = [super initWithFrame:frame context:context])) {
    UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onTap:)];
    [tapRecognizer setNumberOfTapsRequired:1]; // количество тапов, необходимых для вызова события
    [tapRecognizer setNumberOfTouchesRequired:1]; // количество пальцев, участвующих в тапе, необходимых для вызова события
    [self addGestureRecognizer:tapRecognizer];
  }
  return self;
}

Тут же, в MainView.m объявим безымянную категорию для приватных методов:

@interface MainView ()
- (void)onTap:(UITapGestureRecognizer *)recognizer;
@end

И объявление метода-обработчика:

- (void)onTap:(UITapGestureRecognizer *)recognizer {
  CGPoint position = [recognizer locationInView:self]; // координаты тапа
  [[NSNotificationCenter defaultCenter] postNotificationName:@"Tap" object:[NSValue valueWithCGPoint:position]];
}




Работа с ресурсами


Работа с ресурсами в Xcode реализована без учета нужды постоянно изменять содержимое проекта. Существует метод борьбы и с этим недугом. Что бы этот способ менеджмента ресурсов сработал, необходимо соблюсти одно правило - все ресурсы объеденены в одной папке.
Добавляем папку с ресурсами в проект как папку (на рисунке папка "data"):
xcode-add-folder | Игры на OpenGL для iOS. Быстрый старт.

Для таргета создадим Run Script после линковки с библиотеками и фремворками:
xcode-run-script | Игры на OpenGL для iOS. Быстрый старт.

Код скрипта:

touch -cm ${SRCROOT}/data

{SRCROOT} - путь к папке, в которой лежит *.xcodeproj.
Суть скрипта в изменении даты последнего изменения папки. Xcode при билде перезаливает в бандл ресурсы, только при увеличении даты последнего изменения ресурса.
Так как у папки эта дата меняется крайне редко, таким образом мы обеспечим обновление ресурсов при каждой сборке.
Однако, доступ к ресурсам, лежащим таким образом, через

[[NSBundle mainBundle] pathForResource:@"data/textures/image" ofType:@"png"]

не получить.
Для получения полного пути к ресурсу, напишем макрос:

#define ResourePath(relative) [[BUNDLE resourcePath] stringByAppendingPathComponent:[@"data" stringByAppendingPathComponent:relative]]

Пример использования:

UIImage *image = [UIImage imageWithContentsOfFile:ResourePath(@"textures/image.png")];


Первоисточник.



Сеть


Фреймворк CoreFoundation предоставляет нам несколько способов создания сокетного клиента. Самый простой, на мой взгляд, это использование CFStreamCreatePairWithSocketToHost(). На вход отдаём адрес хоста и порт, на выходе имеем NSInputStream и NSOutputStream, минуя весь деревянный API berkley сокетов.

Предположим, что классы стримов у нас объявлены:

NSInputStream *_inputStream;
NSOutputStream *_outputStream;

Тогда инициализация будет выглядеть так:

CFReadStreamRef readStream = NULL;
CFWriteStreamRef writeStream = NULL;
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault, (__bridge CFStringRef)@"gamedev.ru", 31337, &readStream, &writeStream);
if (readStream != NULL && writeStream != NULL) {
  CFReadStreamSetProperty(readStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue); // закрывать сокет при возникновении ошибки или уничтожении стрима
  CFWriteStreamSetProperty(writeStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue);

  _inputStream = (__bridge_transfer NSInputStream *)readStream;
  _outputStream = (__bridge_transfer NSOutputStream *)writeStream;

  [_inputStream setDelegate:self];
  [_inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
  [_inputStream open];

  [_outputStream setDelegate:self];
  [_outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
  [_outputStream open];
}

Дальнейшее описание работы со стримами можно почитать в доке.

Если вы решили реализовать обработку событий стримов через RunLoop, логично было бы сделать обработку сетевого ввода/вывода в отдельном потоке. Реализовывается это не совсем тривиально.
Код инициализации изменится на следующий:

CFReadStreamRef readStream = NULL;
  CFWriteStreamRef writeStream = NULL;
  CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault, (__bridge CFStringRef)HOST, 31337, &readStream, &writeStream);
  if (readStream != NULL && writeStream != NULL) {
    CFReadStreamSetProperty(readStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue);
    CFWriteStreamSetProperty(writeStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanTrue);
        
    _inputStream = (__bridge_transfer NSInputStream *)readStream;
    _outputStream = (__bridge_transfer NSOutputStream *)writeStream;
        
    _thread = [[NSThread alloc] initWithTarget:self selector:@selector(onBackgroundThread:) object:nil]; // NSThread *_thread; Поле класса
    [thread start];
}

Тело цикла нового потока:

- (void)onBackgroundThread:(NSThread *)thread {
  [[NSRunLoop currentRunLoop] addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; // инициализация RunLoop'а для нового потока
  
  [_inputStream setDelegate:self];
  [_inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
  [_inputStream open];
  
  [_outputStream setDelegate:self];
  [_outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
  [_outputStream open];
  
  while ([[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]); // бесконечно обрабатываем события
}

Что бы не прибегать к синхронизациям при работе с очередями ввода/вывода, не забываем про -performSelector:onThread:withObject:waitUntilDone: и в качестве потока передаём созданный выше _thread.

#gesture, #glkit, #iOS, #сеть, #OpenGL ES, #socket client

21 января 2014 (Обновление: 24 янв 2014)