|
Independente do jogo sendo criado ser 2D ou 3D, este sempre vai utilizar imagens digitais de alguma forma, veremos neste artigo como funcionam as imagens digitais e alguns exemplos de como fazer manipulação dos pixels de uma imagem em C++.
Imagens
No contexto desse artigo imagens são as estruturas de dados que usamos para armazenamento de imagens em um computador, como uma foto ou desenho. Primeiramente temos que fazer uma separação entre o formato de disco e o formato da memória. Existem diversos formatos de arquivo de imagens, como: jpg, png, bmp, pcx, tga, etc. Alguns possuem compressão com perda de dados, outro apenas uma compressão sem perda e alguns nem sequer usam qualquer tipo de compressão.
Alguns algoritmos de compressão chegam a ser bem complexos, casos do jpg e o png, inclusive alguns possuem patentes e restrições quanto ao uso livre (jpg em especial). Um detalhe é que a compressão de imagens na maioria das vezes costuma ser uma compressão com perda, ou seja, a imagem comprimida não é exatamente igual a imagem original, apesar de que na maioria das vezes a diferença não ser perceptível aos olhos humanos, elas existem. Alguns formatos (como o pcx, por exemplo) possuem compressão sem perda, nesse caso a imagem comprimida depois de descompactada passa a ser idêntica a original, mas estas técnicas não costuma reduzir muito o tamanho da imagem e em alguns casos podem até aumentar o tamanho da mesma.
Outro detalhe sobre as imagens é que após carregadas em memória todas seguem um mesmo formato, existem algumas diferenças que veremos aqui, mas não passam de uma matriz de pixels. O Pixel ou Picture Element é a menor unidade de dados de uma imagem, cada pixel representa um ponto da imagem (armazenando sua cor).
Cada imagem possui um tipo de pixel, que geralmente varia em 8, 16, 24 ou 32 pixels, que significa quantos bits são precisos para se armazenar cada pixel da imagem. A partir de um pixel podemos saber qual é a cor daquele ponto da imagem, para isso o Pixel nos diz o valor RGB daquele ponto. No formato de 8 bits cada pixel armazena o índice do valor RGB e o valor RGB é armazenado em uma palheta de cores (que veremos mais a frente). Já nos demais formatos os pixels costumam armazenar diretamente os valores RGB.
No formato 16 bits podemos ter,por exemplo, a combinação: 6 bits vermelho, 5 bits azul, 5 bits verde, mas variações podem existir e para saber o formato exato de um pixel é preciso checar no arquivo sendo carregado qual o formato que este utiliza (a informação pode estar no cabeçalho do arquivo ou faz parte da documentação). No modo 24 bits o comum é que sejam usados 8 bits para cada valor de cor e no modo 32 bits geralmente são usados 8 bits para cada cor, mas ao invés de cada pixel representar o RGB, este representa um RGBA (Red, Green, Blue e Alpha), sendo o canal alpha usado para controle de transparências.
Um fato importante é que não existe regra para o formato de um pixel, eles apenas vão armazenar as cores RGB, sendo que, por exemplo, um pixel 24 bits pode armazenar as cores usando formatos como: RGB, BGR, RBG, etc. Para saber como cada componente de cor esta sendo representado, deve-se consultar o arquivo da imagem ou a documentação do formato sendo usado.
O tamanho dos pixels nos diz quantas cores a imagem pode utilizar ao mesmo tempo, dos tamanhos mencionados podemos então dizer:
- 8 bits: 256 Cores
- 16 bits: 65536 cores
- 24 e 32 bits: 16 milhões
Note que 24 e 32 bits possuem a mesma quantidade de cores, pois como cada pixel no formato 32 bits armazena o RGB e o canal Alpha, temos na verdade apenas 24bits de cores, pois o alpha não armazena cores.
De maneira simplificada podemos dizer que um arquivo de imagens precisa armazenar apenas:
- Dimensões da imagem (largura e altura em pixels)
- Formato de pixel (8, 16, 24, etc e disposição dos valores RGB)
- Pixels
- Palheta de cores
Palheta de cores
Como foi mencionado imagens de 8 bits (imagens que possuem pixels de 8 bits) costumam utilizar uma palheta de cores. A palheta de cores nada mais é do que um array que armazena o valor RGB (que pode ser de 24 bits) de cada possível pixel da imagem. A vantagem dessa técnica é que se precisa de menos memória para se guardar a imagem, pois a quantidade de pixels geralmente é muito maior que a quantidade de entradas na palheta. Se pegarmos, por exemplo uma textura do jogo Duke Nuken 3d que tipicamente possuía dimensões de 64x64 pixels, teremos um total de 4096 pixels. Se cada imagem possuísse uma palheta única (o que não era o caso) seriam precisos apenas 768 bytes para armazenar uma palheta de 256 cores. Assim no total a imagem precisaria de apenas 4864 bytes (ignorando o cabeçalho, que geralmente é bem pequeno). Se a mesma imagem fosse armazenada utilizando um formato de 24 bits (sem palheta), a mesma imagem precisaria de 12288 bytes!
Outro detalhe da palheta de cores é que o hardware de vídeo quando esta trabalhando no modo de 8 bits tem suporte nativo a palheta, era comum assim nos jogos antigos usar centenas (ou milhares de imagens) 8 bits e apenas uma palheta. O jogo podia então carregar a palheta, configurar o dispositivo de vídeo para usar a palheta e carregar as imagens diretamente para o vídeo. É importante notar que o hardware suportava apenas uma palheta por vez, ou seja, se o jogo desenhasse 2 imagens diferentes na tela e trocasse a palheta de cores, ambas imagens seriam afetadas. Apesar de ser uma limitação isso em mão habilidosas se tornava um recurso bem poderoso, permitindo aos jogos mudarem completamente as cores de tudo apenas trocando a palheta de cores. Esse efeito era muito comum quando se pegava um power up ou outro item qualquer que causava algum efeito no vídeo, na imagem abaixo temos uma imagem do jogo Doom antes e depois do jogador pegar uma roupa de proteção. O efeito verde da tela era feito apenas trocando-se a palheta de cores.![]()
Além do efeito visual era possível inclusive criar animações apenas manipulando a palheta, um exemplo pode ser visto neste site (requer navegador com suporte a HTML 5).
Manipulando imagens
Agora que vimos como as imagens funcionam vamos criar um pequeno programa para por em pratica os conhecimentos adquiridos. Este programa fara uso da SDL, mas os conhecimentos apresentados certamente pode ser usados em qualquer outra biblioteca.
O programa carrega uma imagem e exibe a mesma duas vezes, sendo que no topo da tela ele exibe a imagem original e na parte de baixo a imagem manipulada. O programa permite então ao usuário manipular a imagem, sendo possível:
- Mostrar apenas o canal Vermelho
- Mostrar apenas o canal Azul
- Mostrar apenas o canal Verde
- Mostrar a imagem “rosada”
- Mostrar a imagem em tons de cinza
Para aplicar um efeito na imagem basta pressionar as teclas de 1 a 5. Abaixo temos o código fonte do loop principal do programa:
static void MainLoop()
{
Uint32 last=SDL_GetTicks();
SDL_Surface *logo = SDL_LoadBMP("logo.bmp");
if(logo == NULL)
{
fprintf(stderr, "Não foi possivel carregar logo.bmp\n");
return;
}
//Criando uma surface auxiliar e convertendo todas para um formato de trabalho
SDL_Surface *work = SDL_DisplayFormat(logo);
SDL_FreeSurface(logo);
logo = SDL_DisplayFormat(work);
bool exit = false;
while(!exit)
{
Uint32 current=SDL_GetTicks();
Uint32 elapsed=current-last;
if(elapsed<10)
continue;
last = current;
SDL_Event ev;
//checando input
while ( SDL_PollEvent(&ev) )
{
switch(ev.type)
{
case SDL_QUIT:
exit = true;
break;
case SDL_KEYDOWN:
//ESC?
switch(ev.key.keysym.sym)
{
case SDLK_ESCAPE:
exit = true;
break;
case SDLK_1:
case SDLK_KP1:
CreateRedImage(work,logo);
break;
case SDLK_2:
case SDLK_KP2:
CreateGreenImage(work,logo);
break;
case SDLK_3:
case SDLK_KP3:
CreateBlueImage(work,logo);
break;
case SDLK_4:
case SDLK_KP4:
CreatePinkImage(work,logo);
break;
case SDLK_5:
case SDLK_KP5:
CreateGreyscaleImage(work,logo);
break;
}
break;
}
}
// Limpa a tela
SDL_FillRect(pScreen_gl, NULL, SDL_MapRGB(pScreen_gl->format, 0, 0, 0));
SDL_Rect rect;
rect.x = 0;
rect.y = 0;
rect.w = logo->w;
rect.h = logo->h;
SDL_BlitSurface(logo, NULL, pScreen_gl, &rect);
if(work != NULL)
{
rect.x = 0;
rect.y = logo->h;
SDL_BlitSurface(work, NULL, pScreen_gl, &rect);
}
//Atualiza tela
SDL_UpdateRect(pScreen_gl, 0, 0, 0, 0);
}
}
O programa primeiramente carrega a imagem (logo.bmp), depois converte ela para o formato usado pela tela (assim a imagem vai ter pixels no mesmo formato usado pelo seu vídeo) e depois cria uma cópia da imagem, que será a imagem a ser modificada. Detalhe que forçamos o vídeo para modo 32 bits, assim as imagens sempre vão ter 32 bits por pixel.
Após carregada a imagem entramos no loop principal. As rotinas de tempo são usadas apenas para evitar que o programa não dispare e fique consumindo CPU de maneira desnecessária (um software ecologicamente correto).
Dentro do loop temos o processamento de eventos e os comandos do usuário. Note que as funções abaixo são chamadas de acordo com o numero:
- CreateRedImage: 1
- CreateGreenImage: 2
- CreateBlueImage: 3
- CreatePinkImage: 4
- CreateGreyscaleImage: 5
As quatro primeiras funções simplesmente fazem uso da função CreateSingleChannelImage que é mostrada abaixo:
static void CreateSingleChannelImage(SDL_Surface *work, const SDL_Surface *logo, Uint32 mask)
{
for(int y = 0;y < logo->h; ++y)
{
for(int x = 0; x < logo->w; ++x)
{
Uint32 *srcPixel = (Uint32 *)logo->pixels + (y * logo->pitch / 4) + x;
Uint32 *destPixel = (Uint32 *)work->pixels + (y * work->pitch / 4) + x;
*destPixel = mask & *srcPixel;
}
}
}
Esta função percorre uma imagem pixel a pixel e extrai de cada pixel apenas um canal de cor (que pode ser o vermelho, verde ou azul) e grava na imagem de destino apenas este canal. Desta forma podemos invocar ela e pedir que remova todas as cores deixando apenas, por exemplo, o canal azul da imagem.
Os dois loops feitos com for são bem simples, apenas percorrem a imagem com base na sua altura e largura (campos h e w da SDL_Surface). A estrutura SDL_Surface armazena os pixels da imagem no campo chamado pixels, que é um ponteiro void e os pixels são armazenados em forma de uma array, podendo ser acessados como matrizes usando a mesma formula mostrada nesse artigo.
Note que a formula para se acessar um pixel é: matriz[y * largura_da_matriz + x], mas no caso de imagens temos que prestar atenção a unidade sendo usada, nesse caso em pixels e em bytes. Os campos w e h da SDL_Surface indicam a largura e altura da imagem em pixels, sendo assim para calcularmos a posição de um pixel na imagem temos que calcular o deslocamento em bytes e não em pixels, neste ponto que entra o campo pitch. Este campo indica qual o comprimento em bytes de cada linha da imagem.
Um detalhe importante sobre o pitch é que ele não foi feito apenas para facilitar o acesso aos campos da imagem, pois a principio bastaria apenas multiplicar y pela largura em pixels e pelo numero de bytes de cada pixel para obtermos o mesmo valor. Isso quase funciona, mas as linhas das imagens possuem bytes de alinhamento, assim toda a linha começa em um endereço de memória com o alinhamento apropriado para plataforma em questão, dessa forma, é bem possível que cada linha possua alguns bytes extras para que o alinhamento seja mantido, por isso é sempre recomendado usar o campo pitch ao invés de tentar calcular o valor de y com base no formato do pixel.
Depois de criado um ponteiro para cada pixel da imagem, é feito um AND binário para mantermos apenas a cor desejada daquele canal, vamos ver em mais detalhes como isso funciona a seguir.
Manipulando um pixel
Para vermos como funciona um pixel vamos imaginar que temos um pixel amarelo de 24 bits no formato RGB, este teria valor 0xFFFF00 (vermelho e verde no máximo). Sendo que em binário teríamos: 11111111.11111111.00000000. Se quisermos deixar apenas o canal vermelho, temos que modificar o pixel para 11111111.00000000.00000000 ou em hexa: 0xFF0000. A maneira mais simples de se fazer isso é criar uma mascara para cor, dessa forma para isolarmos os pixels vermelhos podemos aplicar o operador & binário usando o valor: 11111111.00000000.00000000. Fazendo a operação teremos: 0xFFFF00 & 0xFF0000, que resulta em 0xFF0000.
Vamos repetir a mesma operação com um azul claro (0x0080FF, em binário 00000000.1000000.11111111). Aplicando a mascara de cores para extrairmos apenas o verde (0x00FF00 ou 00000000.11111111.00000000) teremos como resultado um verde intermediário: 0x008000 ou em binário 00000000.100000000.00000000.
Note que com o simples uso de mascaras de bits é possível eliminar totalmente um canal de cor de um pixel, ficando simples agora entender o código da função CreateSingleChannelImage.
Manipulando pixels com a SDL
No exemplo anterior, vimos que sabendo o formato do pixel é fácil criar uma mascara de bits para manipular cores, mas como a SDL nos ajuda nesse trabalho? A resposta é que toda SDL_Surface possuí um ponteiro para uma estrutura chamada SDL_PixelFormat, estrutura esta que possui campos com as mascaras para extração de cada canal, sendo que a implementação da função CreateRedImage apenas invoca a função CreateSingleChannelImage usando a mascara apropriada, como mostrado abaixo:
static void CreateRedImage(SDL_Surface *work, const SDL_Surface *logo)
{
CreateSingleChannelImage(work, logo, logo->format->Rmask);
}
As demais funções fazem quase o mesmo, apenas trocam a mascara de cor. Dessa maneira podemos modificar completamente as imagens de uma maneira bem simples.
Nível de cinza
Por fim vamos ver como criar uma imagem de tons de cinza, que nada mais é do que uma imagem onde todas as cores são formadas por variações da cor cinza. Sendo o preto o valor com menor intensidade e o branco a cor com maior intensidade. Para se converter uma cor RGB para uma cor em tons de cinza precisamos deixar os três valores RGB iguais. Uma maneira simples de se fazer isso é somar os três valores e se dividir por 3. Isso nos gera um valor razoável, mas o ideal é levar em consideração a sensibilidade do olho humano para cada canal de cor, usamos então pesos para cada cor, sendo:
- Vermelho: 30%
- Verde: 59%
- Azul: 11%
Assim temos uma conversão mais suave, o código SDL para isto é mostrado abaixo:
static void CreateGreyscaleImage(SDL_Surface *work, const SDL_Surface *logo)
{
for(int y = 0;y < logo->h; ++y)
{
for(int x = 0; x < logo->w; ++x)
{
Uint32 *srcPixel = (Uint32 *)logo->pixels + (y * logo->pitch / 4) + x;
Uint32 *destPixel = (Uint32 *)work->pixels + (y * work->pitch / 4) + x;
float red = (float) (((*srcPixel) & logo->format->Rmask) >> logo->format->Rshift);
float green = (float) (((*srcPixel) & logo->format->Gmask) >> logo->format->Gshift);
float blue = (float) (((*srcPixel) & logo->format->Bmask) >> logo->format->Bshift);
float color = (red * 0.3f) + (green * 0.59f) + (blue * 0.11f);
color = ((int)color) + 0.5f;
Uint8 finalColor = color > 255 ? 255 : (int) color;
*destPixel = SDL_MapRGBA(work->format, finalColor, finalColor, finalColor, 0);
}
}
}
O inicio do código acima e os loops são idênticos ao anterior, a diferença é como são extraídos os valores RGB do pixel. Note que é usado o operador & para extrair apenas o canal desejado, depois é feito um shift (utilizando-se o “>>”) para converter o valor para um número entre 0 e 255. Todas as operações são feitas utilizando-se as informações contidas na estrutura SDL_PixelFormat, acessada através do campo “format” da SDL_Surface.
Após extraída as cores calculamos uma nova cor utilizando as proporções discutidas anteriormente e por fim criamos uma novo pixel utilizando a função SDL_MapRGBA da SDL. Por fim atualizamos o pixel da imagem de destino e no final temos nossa imagem em tons de cinza.
Considerações finais
A SDL não é a única biblioteca existente que permite manipulação de imagens e nada impede o desenvolvedor de definir suas próprias estruturas de dados, escrever suas próprias rotinas para carregar imagens e até mesmo seus próprios formatos, mas a SDL foi escolhida devido a sua simplicidade de uso e entendimento, além de que a estrutura de dados que esta utiliza é similar a de outras bibliotecas.
Por fim, o enfoque do artigo é mostrar os conceitos de organização de imagens digitais e como utilizar esses conceitos na prática, sendo estes conceitos os mesmos na maioria dos sistemas de imagens, principalmente em jogos.
Caso queira explorar outras formas de trabalhar com imagens, algumas opções são:
- SDL_Image: extensão oficial da SDL que permite utilizar diferentes formatos de imagens, como png, gif, etc.
- FreeImage: biblioteca de código aberto que suporta diversos tipos de imagens além de trazer inúmeras rotinas de manipulação.
- DevIL: outra biblioteca de código aberto para manipulação de imagens, possui uma interface inspirada no OpenGL e integra-se com este facilmente.
Código fonte
O código fonte do programa exemplo pode ser obtido no projeto Google Code do PontoV clicando-se aqui. Se preferir uma versão zipada do código esta se encontra aqui.
Além do código uma versão já compilada para Windows pode ser encontrada aqui.
O código foi feito com o objetivo de ser portável, mas o mesmo foi testado apenas no Windows utilizando Visual Studio 2008, então é possível que exista algum problema de portabilidade em outras plataformas.
Referências
- Doom Wiki (imagens do Doom): http://doom.wikia.com/wiki/Entryway











Muito bom artigo. Parabéns!