Criando um Buffer de Imagem no MacOS do Zero
Table of Contents
Hoje quero compartilhar como fiz para alocar um buffer de image para renderização gráfica na tela por meio de software (CPU) no MacOS manualmente.
Há duas etapas principais.
- Alocar memória onde os pixels persistem.
- Aplicar o buffer que está fora da tela para permitir que os usuários visualizem os pixels na tela (chamamos isso de blit).
Antes de começar, quero adicionar algumas definições para o nosso código. A palavra-chave static pode ter significados diferentes dependendo de onde você a coloca.
Portanto, não há problema em usar um pré-processador #define para definir uma palavra-chave "nova", se desejar.
Além disso, também podemos definir novos tipos para harmonizar o código, principalmente para tamanhos de inteiros e pontos flutuantes.
Você pode declarar:
#define internal static // para funções #define local_persist static // para variável local que persiste #define global static // para escopo global que inicializa tudo com zero. #include <stdlib.h> // nome curto para inteiros typedef int8_t s8; typedef int16_t s16; typedef int32_t s32; typedef int64_t s64; typedef uint8_t u8; typedef uint16_t u16; typedef uint32_t u32; typedef uint64_t u64; typedef float f32; typedef double f64;
No meu projeto, eu estou usando só a definição de internal para as funções e os novos nomes de tipos.
Alocar Memória
Precisamos de um local para armazenar a memória que representará os pixels do jogo (leia-se bitmap).
Criei uma nova função para alocar o buffer. Nessa função, garanto que haja apenas uma alocação. Ou seja, se eu chamá-la novamente, primeiro libero a memória anterior e aloco uma nova.
Essa regra é importante se você quiser trabalhar com o resize de janela e realocar um novo buffer para o novo tamanho da janela.
Vejamos o código:
struct OSX_Back_Buffer
{
u8* memory;
u64 memory_size;
int width;
int height;
int bytes_per_pixel;
int pitch;
};
Note que essa funcão depende de uma struct que será a estrutura que agrupará todas as propriedades importantes para o buffer.
Nele teremos:
- memory: que representa o ponteiro para um espaço virtual alocado na memória pelo mmap. O mmap é uma chamada do sistema, um nível mais baixo que malloc que pode ser usado em conjunto com arquivos por exemplo. Com ele, armazenamos espaços por blocos de página de 4Kb.
- bytes_per_pixel: quantos bytes cada pixel vai exigir. Para ARGB seria 4 bytes = 32 bits.
- memory_size: o tamanho da memória em bytes. Neste caso será o tamanho da janela (largura * altura * bytes por pixel).
- width: largura da janela.
- height: altura da janela.
- pitch: bytes por linha. Quantos bytes há em uma única linha para podermos representar uma estrutura 2D em memória. Em alguns lugares pode ser referenciado como stride.
internal void
create_back_buffer(OSX_Back_Buffer *back_buffer,
int width, int height)
{
if (back_buffer->memory) {
if (munmap(back_buffer->memory, back_buffer->memory_size) != 0) {
printf("Unable dealloc memory of back buffer!\n");
}
back_buffer->memory = NULL;
back_buffer->memory_size = 0;
}
back_buffer->width = width;
back_buffer->height = height;
int bytes_per_pixel = 4;
u64 memory_size = width * height * bytes_per_pixel;
back_buffer->bytes_per_pixel = bytes_per_pixel;
back_buffer->memory_size = memory_size;
back_buffer->pitch = width * bytes_per_pixel;
back_buffer->memory = (u8 *) mmap(NULL,
(size_t) memory_size,
PROT_READ|PROT_WRITE,
MAP_ANON|MAP_PRIVATE,
-1, 0);
}
Como estamos armazenando dados de leitura e escrita na memória virtual, colocamos as flags PROT_READ e PROT_WRITE no mmap.
O munmap é usado para livrar espaços de memória, semelhante ao free(void*) quando usamos malloc.
Agora, como definir se vamos usar RGB, RGBA, ARGB, etc?
Precisamos criar um contexto de bitmap com as informações deste bitmap e um contexto da API de gráficos do macOS nativa, o Core Graphics.
Core Graphics no MacOS (Quartz)
O Core Graphics, também conhecido como Quartz, é a API de nível mais baixo dos sistema da Apple como iOS e macOS para renderização entre um dispositivo como o monitor e o hardware (placa de video).
É uma API muito antiga e é usada por baixo dos panos de outras APIs do MacOS como o AppKit ou UIKit para o iOS.
Aqui, ao invés de usarmos o AppKit que acaba trabalhando ao estilo OOP do objective-C, iremos fazer algo mais low-level.
Vamos criar nosso próprio Contexto de um Bitmap e exigir o Blit para o sistema operacional.
A primeira coisa é definir como os bytes serão representados na memória para que o dispositivo compreenda quais canais iremos utilizar e que cor haverá em cada canal.
Logo após criar a memory, vamos alocar um CGColorSpace.
No Objective-C, tudo que começa com CG e termina com Ref é um ponteiro de algo do Core Graphics.
E tudo que é criado com Create**, DEVE ter o seu Release respectivamente no programa para evitar vazamento de memória.
struct OSX_Back_Buffer
{
// outras propr...
CGContextRef bitmap_context;
};
Adicionei na struct o trecho CGContextRef bitmap_context que será o ponteiro para o contexto de bitmap.
// memory... CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); back_buffer->bitmap_context = CGBitmapContextCreate(back_buffer->memory, width, height, 8, width * bytes_per_pixel, colorSpace, kCGImageAlphaNoneSkipFirst|kCGBitmapByteOrder32Little); CGColorSpaceRelease(colorSpace);
No exemplo acima estamos criando um espaço de cores do tipo RGB e informando que a memória é composta por 32 bits por pixel e cada componente possui 8 bits, totalizando 4 canais.
Sendo que o primeiro canal, iremos desconsiderar pois não usaremos alpha por hora. Isso é feito com kCGImageAlphaNoneSkipFirst.
Além disso, informamos que a ordem de armazenamento dos bits nos registradores segue o padrão Little Endian.
Para ver os formatos de pixels suportados pelo Core Graphics, siga o manual da Apple.
E como havia mencionado, se houve Create, deve haver Release.
Como não precisamos mais do ColorSpace depois de ter criado o contexto do bitmap, podemos nos livrar dele.
Agora, no início do programa, podemos criar o back buffer com o tamanho da nossa janela.
// variavel global internal OSX_Back_Buffer back_buffer; // função main create_back_buffer(&back_buffer, WINDOW_WIDTH, WINDOW_HEIGHT);
Testando com Gradiente
Com o back buffer pronto, podemos escrever nessa memória respeitando o formato dos pixels de xx RR GG BB.
Crie a função de renderização do gradiente.
internal void
render_weird_gradient(OSX_Back_Buffer *back_buffer, int xo, int yo)
{
int width = back_buffer->width;
int height = back_buffer->height;
int pitch = back_buffer->pitch;
u8 *row = back_buffer->memory;
for (int y = 0; y < height; y++) {
int *pixel = (int *) row;
for (int x = 0; x < width; x++) {
u8 r = 0;
u8 g = (x + xo);
u8 b = (y + yo);
*pixel++ = (r << 16) | (g << 8) | b;
}
row += pitch;
}
}
O mais importante aqui é o cast para u8 para garantir que cada canal possa ter no máximo 255 bits, que corresponde à uma cor RGB.
Agora dentro do loop principal, podemos movimentar esses pixels e fazer o blit na tela.
while (!should_quit) {
@autoreleasepool {
NSEvent *event;
while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny
untilDate:[NSDate distantPast]
inMode:NSDefaultRunLoopMode
dequeue:YES])) {
[NSApp sendEvent:event];
}
render_weird_gradient(&back_buffer, x, y);
x++;
y++;
// NOTE(tiago): Autorelease pool foi extremamente necessário
// evitar memory leak.
[[window contentView] display];
}
}
Mesmo que possamos agora movimentar os pixels na memória, precisamos de alguma maneira forçar o blit da janela e isso é feito do "jeito Apple" com NSView.
A NSView é a classe principal dentro de uma NSWindow que possui métodos que são disparados pelo sistema operacional como quando fazemos um redimensionamento da janela.
A única maneira de fazer o render final é através de uma classe NSView.
O problema é que se fizermos ao "estilo Apple", teriamos que depender do loop principal do macOS e esperar que ele decida quando aplicar o desenho na tela.
Mas aqui eu quero ter o controle do loop principal e tomar essa decisão por conta próprio, porque dessa maneira eu posso fazer outras coisas no meu loop.
Há uma maneira otimizada para se trabalhar com esse loop em conjunto do blit do monitor usando a precisão do V-Sync e evitando flash brancos não previsíveis em nosso loop.
Afinal, o monitor pode rodar a 60hz e não conseguimos sincronizar esse blit com o nosso render manualmente sem algum código v-sync, mas vamos ver isso depois.
Por hora o que precisamos saber é que o macOS PRECISA de um objeto NSView e é ele que tem o Context da janela ao qual iremos desenhar.
NSView. O Jeito Apple de Renderização
Muito bem, na hora de criar a janela, também vamos configurar no contentView nossa própria NSView capaz de atualizar a janela.
OSXView *view = [[[OSXView alloc] init] autorelease]; [view setBackBuffer:&back_buffer]; [window setContentView:view];
O trecho acima cria um novo objeto da classe OSXview (que vamos criar) e atribui o back buffer lá para trabalhar com ele.
E se você observar bem, no main loop temos o trecho [[window contentView] display] que é o cara responsável por invocar e forçar o sistema desenhar com o método drawRect que vamos sobrescrever daqui a pouco.
O nosso OSXview é assim:
@interface OSXView: NSView
@property (nonatomic) OSX_Back_Buffer *backBuffer;
@end
@implementation OSXView
- (void)drawRect:(NSRect)dirtyRect
{
CGContextRef context = [[NSGraphicsContext currentContext] CGContext];
int width = dirtyRect.size.width;
int height = dirtyRect.size.height;
Assert(self.backBuffer->bitmap_context);
CGImageRef cgImage = CGBitmapContextCreateImage(self.backBuffer->bitmap_context);
CGRect boundingBox = CGRectMake(0, 0, width, height);
CGContextDrawImage(context, boundingBox, cgImage);
CGImageRelease(cgImage);
}
- (void)dealloc
{
[super dealloc];
CGContextRelease(self.backBuffer->bitmap_context);
}
@end
O método drawRect é invocado sempre que o sistema acredita ser importante a renderização e atualização da tela como quando pedimos à ela pelo [[window contentView] setNeedsDisplay:YES] ou quando há um redimensionamento na janela pelo usuário.
O problema de usar o setNeedsdisplay é que ele foge do nosso loop principal, ou seja, ele decide quando aplicar as mudanças.
Isso faz com que a nossa lógica de atualização do buffer aconteça muito mais rápido do que o blit na tela.
O ideal seria forçar esse draw, logo após nosso update de conteúdo, considerando que agora o controle do loop principal é minha responsabilidade.
Por isso, a substituição para o [[window contentView] display] faz sentido aqui.
De volta no drawRect, nós temos:
- CGContextRef: ponteiro para o contexto atual, no caso a janela/monitor que só está disponível dentro de um método NSView como o drawRect;
- CGImageRef: a imagem gerada a partir do contexto de bitmap. Nós vamos aplicar no monitor mas poderia ser escrito em um arquivo, pdf ou impresso com outro dispositivo como impressora;
- CGContextDrawImage: a mágica acontece aqui. Nós passamos ao contexto atual essa imagem e ela é aplicada pelo sistema operacional e "transportada" para a placa de video. Provavelmente operações com GPU devem ocorrer aqui;
- CGImageRelease: livramos memória da image, visto que já usamos ela;
Note que não fazemos release do bitmap porque ainda vamos usar ele no próximo quadro do jogo.
Nós só precisamos modificar a memória a cada quadro para ter uma animação para o nosso jogo.
E por fim, o Release acontece no dealloc do NSView, ou seja, quando estamos para encerrar a janela e terminar o jogo.
Conclusão
Por mais que pareça rápido e até claro agora como tudo funciona, foi demorado e trabalhoso chegar nesse resultado e entender como tudo isso acontece por baixo dos panos.
Fiz diversos testes, quebrei e corrigi bastante código e pesquisei muito na internet para encontrar informações sobre o assunto.
Elas são bem escassas nos dias atuais pois muita coisa é feita diretamente pelos frameworks ou APIs do macOS e iOS que estão em cima do Core Graphics, o que dificulta a pesquisa.
Mas ainda há programadores norte-americanos raízes, com grande conhecimento compartilhando informações extremamente ricas e valiosas que me ajudou nesse processo.
Além disso, a documentação da Apple é fraca de exemplos para se chegar a uma conclusão definitiva por conta própria de como fazer as coisas.
Algumas bibliotecas ajudaram também no processo como o SDL, o GnuSTEP e a implementação feita no módulo do jai (linguagem de programação criada pelo Jonathan Blow).
Espero conseguir evoluir essa versão e começar a ter pixels mais interessantes para um jogo :D
Meu acesso rápido
- Alocar memória com mmap;
- Criar contexto com Core Graphics;
- Modificar os pixels no formato ARGB;
- Criar uma NSView para draw;
- Atualizar o buffer a cada quadro e fazer o blit com NSView;