#gamedev

Como Criar uma Janela (Window) no MacOS e AppKit

Para criar uma janela (a Window) no MacOS com AppKit e programar um jogo é necessário algumas etapas.

Essas etapas não são muito complicadas, exceto pela documentação do macOS da Apple, que é muito escassa em exemplos.

Então, após vasculhar bastante a internet, encontrei diversos snippets de código que me ajudaram a compreender melhor o que a documentação da Apple queria nos dizer (mas não o fez).

Portanto, nas próximas linhas, mostrarei como criar uma janela e configurar o loop principal do jogo.

Todas essas etapas requerem a documentação da Apple. Mostrarei apenas o código e explicarei os pontos principais de como as coisas funcionam por baixo dos panos. Espero que você consulte a documentação ao longo do processo para ler os detalhes de cada função e propriedade.

É muito importante seguir a documentação lado a lado com esta postagem.

Além disso, é necessário um pouco de conhecimento de Objective-C. Boa parte desse artigo será baseado nessa linguagem de programação e na linguagem C.

Entendendo o AppKit

Como mencionei, uma das coisas que tive que vasculhar para entender é como funciona um app no macOS por baixo dos panos.

Assim como todo programa, tudo se inicia na função main. Porém, se formos seguir uma estrutura de projeto template ao estilo Xcode, vamos notar que há uma função chamada NSApplicationMain, que cuida de tudo para nós.

Mas eu não quero apenas usar essa função sem entender o que há nela, ou melhor, eu quero ter controle para criar meus eventos e entender de verdade como a plataforma do macOS funciona.

De fato não temos a implementação dessa função porque ela faz parte da API da Apple, no qual não é pública (assim como o Windows API). Mas há alguns exemplos e pseudo-código na documentação que nos ajuda a entender melhor o que pode estar acontecendo aqui.

Outro projeto que nos ajuda muito nesse processo de entendimento da plataforma é a biblioteca open-source GNUStep que é, segundo ela, um framework para o desenvolvimento de aplicativos desktop onde o núcleo do GNUstep segue de perto as APIs Cocoa da Apple.

Lá pude notar o que acontece em um app macOS para que a janela possa aparecer e possamos interagir com ela.

NSApplication

O NSApplication é o objeto principal de qualquer app macOS, é ele que adminstra o loop de eventos e os recursos do aplicativo.

Se você já criou um projeto Xcode iOS ou macOS, já deve ter se deparado com o AppDelegate que na verdade é uma classe que podemos usar como um handle dos eventos do NSApplication.

Então esse será o objeto que precisaremos iniciar para que o sistema operacional possa trabalhar com os eventos de loop e criação de janelas (Window) para o nosso jogo.

Essa classe chamada de NSApplication possui uma propriedade que irá retornar o nosso objeto Application de maneira compartilhada (singleton). Para isso, devemos no início do programa chamar o [NSApplication sharedApplication].

Essa instância é armazenada em NSApp que é uma variável global e pode ser acessada em qualquer lugar do app.

O NSApp, que é a referência para o nosso NSApplication, possui uma função chamada run, que é responsável por iniciar o loop de eventos principal. Porém, como mencionei, vamos criar o nosso próprio para aprender como as coisas funcionam e não depender somente do NSApplicationMain.

int main(int argc, char **argv)
{
    init_app();
}

void init_app()
{
        [NSApplication sharedApplication];
        NSCAssert(NSApp != nil, @"NSApp not initialized. call [NSApplication sharedApplication] before");
        [NSApp finishLaunching];
}

Como pode notar, o NSApp só é iniciado após o sharedApplication. Então, sempre chame ele como a primeira instrução do programa.

Logo depois, precisamos finalizar com finishLaunching, informando que já carregamos o que gostariamos.

Poderiamos ainda adicionar um objeto sendo o delegate do Application (AppDelegate) para poder ouvir eventos como quando o app foi lançado ou terminado e diversos outros. Mas por enquanto, vamos deixar mais simples possível.

Framework

A etapa final é adicionar uma biblioteca estática AppKit.framework ao arquivo build.sh.

LIBS="-framework AppKit"
COMPILADOR_FLAG="-g"
clang $COMPILER_FLAG ../src/macos_<project_name>_main.mm $LIBS

Criando a Janela no MacOS (NSWindow)

A classe do Objective-C que cria uma janela é a NSWindow. Sem ela não poderiamos ter uma "casca" onde desenhar nossos pixels.

Além disso, é por ela que ouvimos eventos como o teclado ou mouse para interagir com o usuário.

Primeiro, vamos alocar uma NSWindow e iniciar com algumas propriedades já declaradas na sua função inicializadora onde podemos passar algumas flags de como a janela do sistema operacional vai se comportar e onde adicionar ela. Além de propriedades de como a janela vai armazenar o buffer de dados.

int main(int argc, char **argv)
{
      init_app();
      create_window();
}

void create_window()
{
        NSWindow *window =
                [[[NSWindow alloc]
                  initWithContentRect:NSMakeRect(0, 0, 960, 540)
                  styleMask:NSWindowStyleMaskTitled |
                  NSWindowStyleMaskClosable |
                  NSWindowStyleMaskMiniaturizable |
                  NSWindowStyleMaskResizable
                  backing:NSBackingStoreBuffered
                  defer:NO] autorelease];

        [window setBackgroundColor:NSColor.blackColor];
        [window center];
        [window makeKeyAndOrderFront:nil];
}

Vou explicar esses trechos de código de forma enxuta, mas recomendo ler cada propriedade na documentação também.

O AppKit usa a OOP, e muitos dos construtores têm um inicializador mais prático, como mostrado anteriormente.

Os pontos principais são:

  • NSMakeRect: Esse é um auxiliar que torna a criação de um retângulo muito mais fácil;
  • styleMask: Define como a janela irá se comportar como redimensionamento, maximar, minimizar, etc;
  • NSWindow: A própria janela representada pelo padrão NS da Apple;
  • NSBackingStoreBuffered: Especifica como o desenho feito na janela é armazenado em buffer pelo dispositivo da janela. Atualmente, precisamos renderizar em um buffer de exibição e, em seguida, enviá-lo para a tela.
  • makeKeyAndOrderFront: Move a janela para a frente da lista da tela e ativa a janela principal;
  • autorelease: O jeito Objective-C de livrar memória alocada caso esteja dentro de um bloco @autoreleasepool;

Primeiro definimos as coordenadas e tamanho da janela. Depois as flags de como essa janela será podendo minimizar, redimensionar, fechar, etc.

Em seguida, informamos que o armazenando de dados será no backing store, um sistema onde o sistema operacional do macOS, junto ao Window Server - que é responsável por realmente por a janela na tela para nós através de um processo - vai entender como esses dados devem ser armazenados.

Essa parte aqui é de responsabilidade do sistema operacional e do processo chamado Window Server que cuida de todo gerenciamento de janelas de todos programas e decide como adminstrá-los, bem como passar para a GPU o que é necessário atualizar e etc. Aqui é onde paramos de nos aprofundar, visto que depende do sistema operacional a nível de processos e kernel.

Também marcamos a criação desse objeto com [autorelease] que uma maneira do Objective-C livrar espaços de memória automaticamente através de um pool (vamos ver isso depois).

Em seguida, coloco uma cor de fundo, movo para o centro e chamo o método mais importante aqui: makeKeyAndOrderFront.

Esse método é o que de fato manda um "sinal" ao Window Server do macOS para criar para nós uma janela e por no monitor. Essa comunicação é feita internamente pela API do AppKit/Cocoa, eventualmente através de uma comunicação IPC, que é uma forma de processos se comunicarem internamente via messages como o XPC.

XPC - este é um mecanismo IPC baseado em mensagens Mach. Pode ser mais fácil de implementar em seu programa, escrito em uma linguagem de nível superior a C ou C++. A Apple fornece alguns exemplos de código Objective-C em seus tutoriais sobre como usar XPC.

Você pode ver esse programa no seu Activity Monitor.

1745349549.png

Então basicamente, nós avisamos ao sistema operacional para gerar para nós uma janela, abrir ela com base nessas configurações e que futuramente, vamos ouvir eventos nela.

Aqui o processo já sabe que o nosso programa solicitou uma janela para ele e agora podemos usar esse novo contexto de janela.

Loop de Evento Principal (Main Loop)

O problema é que o programa carrega, abre a janela e já fecha porque ele termina o escopo da função main.

Precisamos criar um event loop para manter o programa em execução e começar a capturar os eventos que o usuário faz na janela como mover, clicar com mouse, fechar no botão vermelho, entre outros.

Com um tradicional while, vamos criar o loop de evento principal e ficar solicitando através do NSApp se há algum evento novo para ser tratado neste momento.

Basicamente, nós vamos "puxar" de uma fila de eventos o que tá acontencendo com a janela.

Alguns eventos são enviados pelo sistema operacional como notificações e etc. E esses eventos acabam que não aparecendo com um NSEvent. Mas vamos chegar lá.

// escopo global
static bool should_quit;

// dentro da main

// create_window()
while (!should_quit) { // boolean
    @autoreleasepool {
        NSEvent *event;
        while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny
                         untilDate:nil
                         inMode:NSDefaultRunLoopMode
                         dequeue:YES])) {
            [NSApp sendEvent:event];
            printf("Event is %lu\n", event.type);
        }
    } // @autoreleasepool
 }

O que acontece aqui é que vamos ficar buscando da fila de eventos, qualquer tipo de evento, e caso ele exista, vamos processá-lo com sendEvent.

Você vai notar que a CPU vai para 100%. Isso porque ficamos o tempo todo pedindo novos eventos.

A maneira de evitar isso seria implementar um "sleep" neste main loop com uma taxa fixa de quadros (frame-rate) ou usar V-Sync para notificar-nos com base na atualização de quadros do monitor. Porém, teriamos que implementá-lo e delegar nosso frame-rate para o AppKit, e não é o caso agora.

Ao imprimir no console, podemos ver eventos como clique do mouse, clique nos botões da janela e etc.

Então resumidamente, quando o App fornece novos eventos, o objeto NSEvent existirá e precisamos despachá-lo com o sendEvent. Assim, a janela ficará interativa.

Para fazer a janela aparecer na frente do terminal e no Dock do macOS comum, precisamos definir no NSApp o seguinte.

Outro ponto importante é o bloco @autoreleasepool. Como internamente o NSEvent e nextEventMatchingMask pode alocar memória e ele está em um loop, precisamos garantir que tudo que está marcado internamente pelo macOS com autorelease seja liberado em cada ciclo.

// Obrigatório para que o app apareça no Dock e seja tratado como um app
// de interface do usuário, com menu bar, mouse cursor correto, etc.
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];

// O único jeito de trazer a janela para frente quando executado no console.
[NSApp activateIgnoringOtherApps:YES];

Fechando a Janela

Agora precisamos fechar a janela corretamente, na verdade ela fecha, só não encerra o programa.

Para fazer isso, vamos precisar de um delegate.

No macOS e iOS quase tudo é feito via delegate, dessa forma, a Apple evita que tenhamos subclasses dos seus tipos. Ao invés disso, usamos o conceito de protocols para definir nossos tipos que irão cuidar de coisas específicas, sem subclasses.

Nesse exemplo, vamos definir um Delegate para a Window e saber quando o usuário clicou no botão fechar.

É através desse mecanismo que a comunicação entre o NSApp e o nosso App vai atuar.

Com a sintaxe tradicional do Objective-C, vamos declarar e implementar esse delegate.

// dentro da função create_window()
MainWindowDelegate *window_delegate = [[MainWindowDelegate new] autorelease];
[window setDelegate:window_delegate];

// escopo global
#import <AppKit/NSWindow.h>

@interface MainWindowDelegate : NSObject <NSWindowDelegate>
@end

@implementation MainWindowDelegate

- (void)windowWillClose:(NSNotification *)notification {
        // NSWindow *window = notification.object;
        [NSApp terminate:self];
}

@end

Os eventos do app não são capturados como um NSEvent, ao invés disso, ele é capturado por aqui, via notification.

É neste evento que o sistema operacional dispara e podemos fechar por completo o programa, usando o [NSApp terminate:self].

Autoreleasepool

Para fechar, vamos encapsular nossas funções dentro de main no bloco de @autoreleasepool, para que o Objective-C possa desalocar memória aos itens que não são mais necessários graças ao autorelease declarado anteriormente.

int main(int argc, char **argv)
{
      @autoreleasepool {
              // create window...
              // event looping...
              return 0;
      }
}

Conclusão

De fato, os passos são poucos e relativamente simples quando se entende a essência da plataforma, mas a primeira vez pode parecer assustador, justamente por falta de exemplos na documentação da Apple.

Mas basicamente para conseguir criar uma janela, precisamos determinar uma classe NSApplication que representa o App à nível macOS, bem como uma classe NSWindow que contenha o título e as propriedades da janela.

Além disso, devemos incluir o procedimento de delegate da janela para tratar eventos como fechar, redimensionamento, etc.

E por último, criar um loop principal para processar a fila de mensagens de eventos que essa janela pode ter como interação do usuário.

Meu acesso rápido

  • Iniciar o NSApplication
  • Criar o NSWindow
  • Criar Delegate da Janela (interface/implementação)
  • Criar GameLoop
  • Processar fila de eventos com nextEventMatchingMask