Программирование для Mac OS X - пишем OpenGL-приложение с использованием Cocoa

В этой статье будет рассмотрено написание программ на Objective-C/Objectiver-C++, работающих с библиотекой OpenGL. При этом в отличии от предыдущей статьи мы будет делать полноценное Cocoa, использовать Interface Builder для дизайна окна, использовать стандартные классы для загрузки текстур и обрабатывать сообщения от мыши.

Итак нашим первым шагом, как и ранее, будет запуск XCode и выбор типа приложения - 'Cocoa Application'.

Рис 1. Выбор типа приложения.

Дадим имя проекту - OpenGL Cocoa Example и зададим путь для него.

Рис 2. Задание имени и каталога для приложения.

После создания проекта сразу же приступим к работе с файлом MainMenu.nib (вызвав Interface Builder двойным щелчком мыши по этому файлу).

Рис 3. Начало работы с MainMenu.nib.

Для работы с OpenGL в Cocoa удобнее всего использовать объекты, унаследованные от класса NSOpenGLView. Данный класс содержит практически все, что нужно для работы с OpenGL, при этом во многих случаях оказывается достаточным просто переопределить метод drawRect:.

Откроем в окне проекта Interface Builder'а закладку Classes и начнем набирать в поле поиска название класса, от которого мы хотим унаследоваться - NSOpenGLView.

После того, как нужный нам класс будет найден, выделим его и нажмем Enter. Это приведет к созданию нового класса, унаследованного от NSOpenGLView, в качестве его имени будет предложено MyOpenGLView.

Рис 4. Создание нового класса на базе NSOpenGLView.

Далее при помощи команды меню Classes/Create Files for MyOpenGLView... создадим необходимые файлы для этого класса (рис 5) и добавим их к текущему проекту (рис 6).

Рис 5. Создание .m и .h файлов для нового класса.

Рис 6. Сохранение созданных файлов.

Откроем теперь палитру объектов на последней закладке, где мы увидим готовый объект для работы с OpenGL - NSOpenGLView.

Рис 7. Стандартные компоненты для работы с OpenGL и Web.

Перетащим его в окно нашего приложения и выровняем как показано на рис 8.

Рис 8. Расположение OpenGL-компонента в окне.

После этого зададим параметры Autosizing как на рис. 9.

Рис 9. Задание параметров Autosizing.

Далее, поскольку мы хотим использовать объект не класса NSOpenGLView, а объект производного от него класса MyOpenGLView, то нам нужно воспользоваться возможностью Interface Builder'а явно задавать используемый класс. Для этого откроем в инспекторе закладку Custom Class и выберем в качестве реально используемого класса MyOpenGLView.

Рис 10. Задание реально используемого класса.

Помимо явной настройки используемого класса нужно задать ряд атрибутов, непосредственно связанных с работой с OpenGL. Для этого откроем закладку Attributes и зададим параметры OpenGL как показано на рис. 11.

Рис 11. Задание параметров OpenGL.

Перейдем теперь в XCode и в файле MyOpenGLView.m зададим самую простейшую реализацию метода drawRect::

#import	<OpenGL/gl.h>
#import "MyOpenGLView.h"

@implementation MyOpenGLView

- (void) drawRect: (NSRect) bounds
{
    glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

    glFlush ();
}
@end

Обратите внимание, что внутри метода drawRect: мы сразу же используем команды OpenGL. Также обратите внимание, что вывод изображения средствами OpenGL обязательно завершается командой glFlush ().

Если сейчас собрать и запустить наше приложение, то мы увидим черный прямоугольник внутри нашего окна. Обратите внимание, что при изменении размеров окна размеры черного прямоугольника изменяются соответственно.

Рис 12. Работающее приложение.

Изменим наше приложение для вывода чего-нибудь более содержательного, чем просто черный экран. Здесь нам очень пригодится возможность совмещения кода на С++ и кода на Objective-C за счет использования Objective-C++.

Переименуем в XCode файл MyOpenGLView.m в MyOpenGLView.mm (чтобы сообщить компилятору, что данный файл содержит программу на Objective-C++).

Далее добавим в наш проект файлы Vector2D.h, Vector2D.cpp, Vector3D.h, Vector3D.cpp, Vector4D.h и Vector4D.cpp.

Также изменим содержимое файла MyOpenGLView.mm как в следующем листинге.

#import	<OpenGL/gl.h>
#import	<OpenGL/glu.h>
#import "MyOpenGLView.h"
#import	"Vector3D.h"

Vector3D    eye   ( 7, 5, 7 );                // camera position
Vector3D    light ( 5, 0, 4 );                // light position
Vector3D    rot ( 0, 0, 0 );
int         mouseOldX = 0;
int         mouseOldY = 0;
float       angle     = 0;

void    drawBox ( const Vector3D& pos, const Vector3D& size, unsigned texture, bool cull )
{
    float   x2 = pos.x + size.x;
    float   y2 = pos.y + size.y;
    float   z2 = pos.z + size.z;

    glBindTexture ( GL_TEXTURE_2D, texture );
    glEnable      ( GL_TEXTURE_2D );

    if ( cull )
    {
        glCullFace ( GL_BACK );
        glEnable   ( GL_CULL_FACE );
    }
    else
        glDisable ( GL_CULL_FACE );

    glBegin ( GL_QUADS );
                                    // front face
        glNormal3f   ( 0, 0, 1 );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( pos.x, pos.y, z2 );

        glTexCoord2f ( size.x, 0 );
        glVertex3f   ( x2, pos.y, z2 );

        glTexCoord2f ( size.x, size.y );
        glVertex3f   ( x2, y2, z2 );

        glTexCoord2f ( 0, size.y );
        glVertex3f   ( pos.x, y2, z2 );

                                    // back face
        glNormal3f ( 0, 0, -1 );

        glTexCoord2f ( size.x, 0 );
        glVertex3f   ( x2, pos.y, pos.z );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( pos.x, pos.y, pos.z );

        glTexCoord2f ( 0, size.y );
        glVertex3f   ( pos.x, y2, pos.z );

        glTexCoord2f ( size.x, size.y );
        glVertex3f   ( x2, y2, pos.z );

                                    // left face
        glNormal3f ( -1, 0, 0 );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( pos.x, pos.y, pos.z );

        glTexCoord2f ( 0, size.z );
        glVertex3f   ( pos.x, pos.y, z2 );

        glTexCoord2f ( size.y, size.z );
        glVertex3f   ( pos.x, y2, z2 );

        glTexCoord2f ( size.y, 0 );
        glVertex3f   ( pos.x, y2, pos.z );

                                    // right face
        glNormal3f ( 1, 0, 0 );

        glTexCoord2f ( 0, size.z );
        glVertex3f   ( x2, pos.y, z2 );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( x2, pos.y, pos.z );

        glTexCoord2f ( size.y, 0 );
        glVertex3f   ( x2, y2, pos.z );

        glTexCoord2f ( size.y, size.z );
        glVertex3f   ( x2, y2, z2 );

                                    // top face
        glNormal3f ( 0, 1, 0 );

        glTexCoord2f ( 0, size.z );
        glVertex3f   ( pos.x, y2, z2 );

        glTexCoord2f ( size.x, size.z );
        glVertex3f   ( x2, y2, z2 );

        glTexCoord2f ( size.x, 0 );
        glVertex3f   ( x2, y2, pos.z );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( pos.x, y2, pos.z );

                                    // bottom face
        glNormal3f ( 0, -1, 0 );

        glTexCoord2f ( size.x, size.z );
        glVertex3f   ( x2, pos.y, z2 );

        glTexCoord2f ( 0, size.z );
        glVertex3f   ( pos.x, pos.y, z2 );

        glTexCoord2f ( 0, 0 );
        glVertex3f   ( pos.x, pos.y, pos.z );

        glTexCoord2f ( size.x, 0 );
        glVertex3f   ( x2, pos.y, pos.z );

    glEnd ();

    if ( cull )
        glDisable ( GL_CULL_FACE );
}

@implementation MyOpenGLView

- (void) reshape
{
    [super reshape];

    NSRect bounds = [self bounds];
    float  width  = bounds.size.width;
    float  height = bounds.size.height;
    float  aspect = width / height;

    glViewport ( 0, 0, (GLint) width, (GLint) height );

    glMatrixMode   ( GL_PROJECTION );
                                            // factor all camera ops into projection matrix
    glLoadIdentity ();
    gluPerspective ( 60.0, aspect, 1.0, 60.0 );
    gluLookAt      ( eye.x, eye.y, eye.z,   // eye
                     0, 0, 0,               // center
                     0, 0, 1 );             // up

    glMatrixMode   ( GL_MODELVIEW );
    glLoadIdentity ();
}

- (void) drawRect: (NSRect) bounds
{
    glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

    glMatrixMode   ( GL_MODELVIEW );
    glPushMatrix   ();

    glRotatef    ( rot.x, 1, 0, 0 );
    glRotatef    ( rot.y, 0, 1, 0 );
    glRotatef    ( rot.z, 0, 0, 1 );

    drawBox ( Vector3D ( -1, -1, -1 ), Vector3D ( 2, 2, 2 ), 0, false );

    glPopMatrix ();
    glFlush ();
}
@end

Глобальные переменные и функция drawBox не несут в себе никакой Mac OS X - специфики и служат для задания параметров проектирования и поворота объекта.

Посмотрим на метод reshape. Он практически полностью эквивалентен обработчику события reshape в GLUT за исключением того, что новые размеры не передаются явно, а их нужно извлечь при помощи посылки себе сообщения bounds.

В ответ на это сообщение возвращается структура NSRect, содержащее в поле size размер визуального компонента. Обратите внимание, что размер задается вещественными (float) числами.

После того, как размеры получены, следует стандартный код, ничем не отличающийся от соответствующего кода для GLUT.

Следующими шагами будет добавления возможности поворота объекта мышью и поддержки текстур.

Для поддержки вращения объекта мышью нам понадобится переопределить в классе MyOpenGLView методы mouseDown: и mouseDragged:.

Первый из этих методов вызывается при нажатии любой из клавиш мыши (если вы до сих считаете что на Маках у мыши всего одна кнопка, то Вы сильно ошибаетесь). Данный метод получает в качестве параметра указатель на объект класса NSEvent, передающий всю информацию о соответствующем событии. Из этой информации нам нужны лишь координаты точки, в которой произошло нажатие кнопки мыши. После чего данные координаты запоминаются в переменной mouseOld.

- (void) mouseDown: (NSEvent *) theEvent
{
    NSPoint pt = [theEvent locationInWindow];

    mouseOld = [self convertPoint: pt fromView: nil];
}

Для получения координат курсора мыши в момент объекту event посылается сообщение locationInWindow, которое возвращает координаты с системе координат окна. После этого можно воспользоваться методом convertPoint:fromView: класса NSView.

Этот метод служит для перевода координат из системы координат одного view в систему координат другого view. Если вместо указателя на объект класса NSView, из системы координата которого следует перевести координаты, передается nil, то в этом случае происходит перевод из системы координат окна, содержащего данный view.

Mac OS X различает (как и GLUT) простые перемещения мыши (когда ни нажата ни одна кнопка мыши) - им соответствует сообщение mouseMoved: - и перемещения мыши с удержанием какой-либо из кнопок мыши - им соответствует сообщение mouseDragged:.

Подобное деление имеет определенный смысл - чаще всего нужны именно сообщения последнего типа, в то время как сообщения первого типа чаще всего можно успешно игнорировать. Поэтому в Mac OS X есть возможность включать/выключать посылку сообщения mouseMoved: (хотя возможно это связано с наследием NextStep'а и борьбой за его быстродействие).

Все, что нам нужно сделать в методе mouseDragged:, это найти разницу координат мыши между прошлым вызовом и текущим, по этой разнице откорректировать углы поворота и запомнить текущее значение координат в mouseOld. После этого необходимо послать себе сообщение setNeedsDisplay: с параметром YES, чтобы вызвать перерисовку содержимого окна.

Соответствующий код метода приводится ниже.

- (void) mouseDragged: (NSEvent *) theEvent
{
    NSPoint pt = [theEvent locationInWindow];

    pt = [self convertPoint: pt fromView: nil];

    rot.y -= ((mouseOld.y - pt.y) * 180.0f) / 200.0f;
    rot.z -= ((mouseOld.x - pt.x) * 180.0f) / 200.0f;
    rot.x  = 0;

    if ( rot.z > 360 )
        rot.z -= 360;

    if ( rot.z < -360 )
        rot.z += 360;

    if ( rot.y > 360 )
        rot.y -= 360;

    if ( rot.y < -360 )
        rot.y += 360;

    mouseOld = pt;

    [self setNeedsDisplay: YES];
}

Для загрузки текстуры из файла проще всего воспользоваться готовыми классами, предоставляемыми Mac OS X - за счет этого мы фактически получаем готовую поддержку большого количества форматов текстур.

Вынесем код загрузки текстуры из файла с заданным именем в отдельный метод:

- (GLuint) loadTextureFromFile: (NSString *) fileName
{
    NSData * data  = [NSData dataWithContentsOfFile: fileName];

    if ( data == nil )
    {
        NSLog ( @"Unable to load: %@", fileName );

        return 0;
    }

    NSBitmapImageRep * image = [NSBitmapImageRep imageRepWithData: data];

    if ( image == nil )
    {
        NSLog ( @"Unable to load texture" );

        return 0;
    }

    NSLog ( @"Texture loaded" );

    int    bitsPerPixel = [image bitsPerPixel];
    int    bytesPerRow  = [image bytesPerRow];
    GLenum format;

    if ( bitsPerPixel == 24 )
        format = GL_RGB;
    else
    if ( bitsPerPixel == 32 )
        format = GL_RGBA;
    else
        return 0;

    int             width  = [image pixelsWide];
    int             height = [image pixelsHigh];
    unsigned char * imageData = [image bitmapData];

                                  // now flip image vertically
    unsigned char * ptr = (unsigned char *) malloc ( width * height * 4 );

    for ( int row = height - 1; row >= 0; row-- )
        memcpy ( ptr + (height - row) * bytesPerRow, imageData + row * bytesPerRow, bytesPerRow );

    GLuint id;

    glGenTextures     ( 1, &id );
    glBindTexture     ( GL_TEXTURE_2D, id );
    gluBuild2DMipmaps ( GL_TEXTURE_2D, format, width, height, format, GL_UNSIGNED_BYTE, ptr );
    glTexParameteri   ( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
    glTexParameteri   ( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR );

    free ( ptr );

    return id;
}

Как видно из приведенного кода, содержимое файла с текстурой загружается в объект класса NSData (при помощи сообщения класса dataWithContentsOfFile:), по нему создается изображение в виде экземпляра класса NSBitmapImageRep (при помощи сообщения класса imageRepWithData:), после чего строится перевернутое в вертикали изображение и загружается в текстуру OpenGL стандартным образом.

К сожалению загрузки текстуры из файла не следует помещать в метод init* для данного класса. Это связано со спецификой загрузки из nib-файлов объектов вводимых пользователем классов.

Если для стандартных классов Interface Builder может просто положить в nib-файл заархивированный образ компоненты, то с пользовательскими классами он так поступить не может, поскольку не располагает кодом для этих классов.

В результате в nib-файл кладутся некоторые данные, по которым на этапе загрузки специальным образом строится нужный объект. Эта процедура весьма своеобразно и как следствие для инициализации подобных объектов лучше использовать сообщение awakeFromNib, посылаемое каждому объекту после того, как все объекты их nib-файла будут полностью загружены.

Мы воспользуемся альтернативным подходом (фактически это вариант load on demand или lazy evaluation) - в самом методе drawRect: мы проверим идентификатор текстуры (все instance-переменные в Objective-C всегда инициализируются нулем), и если он равен нулю, то создадим текстуру загрузив изображение из файла.

- (void) drawRect: (NSRect) bounds
{
    if ( texture == 0 )
        texture = [self loadTextureFromFile: @"/Users/alex/Tutorials/OpeGL-Cocoa-Example/Oxidated.jpg"];

    glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

    . . .
}

На следующем рисунке приводится скриншот получившейся программы.

Рис 13. Окончательное приложение.

Очень удобным местом для хранения текстур являются ресурсы программы, точнее nib-файл. Для того, чтобы добавить текстуру в nib-файл достаточно открыть закладку Images и перетащить файл с текстурой туда.

Поскольку nib-файл - это на самом деле каталог, являющийся частью каталога приложения, то наш файл с текстурой будет обычным файлом, лежащим где-то внутри каталога приложения. Для получения пути на изображения с заданным именем следует воспользоваться классом NSBundle и его методами.

- (GLuint) loadTextureFromResource: (NSString *) name
{
    NSString * path = [[NSBundle mainBundle] pathForImageResource: name];

    if ( path == nil )
        return 0;

    return [self loadTextureFromFile: path];
}

Выше приведенный метод loadTextureFromResource служит для загрузки в OpenGL текстуры из ресурсов приложения. Для этого сначала необходимо получить указатель на объект класса NSBundle, соответствующий тому nib-файлу, в котором находится требуемая текстура.

Если мы хотим поместить эту текстуру в главный nib-файл (MainMenu.nib), то для доступа к нему удобно воспользоваться методом класса mainBundle, возвращающим указатель на объект класса NSBundle, соответствующим главному nib-файлу.

После этого для получения пути к файлу с изображением по его имени используется сообщения pathForImageResource: (для доступа к звуковым ресурсам можно использовать сообщение pathForSoundResource, для доступа к ресурсам любого типа служит сообщение pathForResource:ofType:).

После того как получен путь к файлу с текстурой, она загружается ранее введенным методом loadTextureFromFile:.

Весь исходный код можно скачать по этим ссылкам 1, 2, 3 и 4.

Valid HTML 4.01 Transitional

Напиши мне
Используются технологии uCoz