1. Introdução
(Links para o código-fonte e o projeto KiCAD são fornecidos no final do artigo.)Embora tenhamos nascido na era dos 8 bits, nosso primeiro computador foi o Amiga 500. Esta é uma ótima máquina de 16 bits com gráficos e sons incríveis, o que o torna ideal para jogos. A plataforma se tornou um gênero de jogo muito popular neste computador. Muitos deles eram muito coloridos e tinham rolagem de paralaxe muito suave. Isso foi possível graças a programadores talentosos que usaram engenhosamente os coprocessadores Amiga para aumentar o número de cores da tela. Dê uma olhada no LionHeart, por exemplo!
Coração de leão em Amiga. Esta imagem estática não transmite a beleza dos gráficos.Desde os anos 90, a eletrônica mudou muito e agora existem muitos microcontroladores pequenos que permitem criar coisas incríveis.
Sempre gostamos de jogos de plataforma e, hoje, por apenas alguns dólares, você pode comprar o Raspberry Zero, instalar o Linux e, "muito fácil", escrever um jogo de plataforma colorido.
Mas essa tarefa não é para nós - não queremos atirar pardais de um canhão!
Queremos usar microcontroladores com memória limitada, e não um sistema poderoso em um chip com uma GPU integrada! Em outras palavras, queremos dificuldades!
A propósito, sobre as possibilidades do vídeo: algumas pessoas conseguem extrair todos os sucos do microcontrolador AVR em seus projetos (por exemplo, no projeto Uzebox ou Craft do desenvolvedor de lft). No entanto, para conseguir isso, os microcontroladores AVR nos forçam a escrever em assembler, e mesmo que alguns jogos sejam muito bons, você encontrará sérias limitações que não permitem criar um jogo no estilo de 16 bits.
Portanto, decidimos usar um microcontrolador / placa mais equilibrado, o que nos permite escrever código completamente em C.
Ele não é tão poderoso quanto o Arduino Due, mas não tão fraco quanto o Arduino Uno. Curiosamente, "Due" significa "dois" e "Uno" significa "um". A Microsoft nos ensinou a contar corretamente (1, 2, 3, 95, 98, ME, 2000, XP, Vista, 7, 8, 10) e o Arduino também seguiu esse caminho! Vamos usar o Arduino Zero, que fica no meio entre 1 e 2!
Sim, de acordo com o Arduino, 1 <0 <2.
Em particular, não estamos interessados no próprio quadro, mas em sua série de processadores. O Arduino Zero possui um microcontrolador da série ATSAMD21 com Cortex M0 + (48 MHz), memória flash de 256 KB e RAM de 32 KB.
Embora o Cortex M0 + de 48 MHz supere significativamente o desempenho do antigo MC68000 de 7 MHz, o Amiga 500 tinha 512 KB de RAM, sprites de hardware, um tabuleiro de jogo duplo integrado, Blitter (um mecanismo de transferência de bloco de imagem baseado em DMA com um sistema de reconhecimento de colisão preciso com pixel) e transparência) e Copper (um coprocessador raster que permite executar operações com registros com base na posição de varredura para criar muitos efeitos muito bonitos). O SAMD21 não possui todo esse hardware (com exceção de um hardware bastante simples em comparação com o Blitter DMA); portanto, muito será renderizado programaticamente.
Queremos alcançar os seguintes parâmetros:
- Resolução 160 x 128 pixels em uma tela SPI de 1,8 polegadas.
- Gráficos com 16 bits por pixel;
- A maior taxa de quadros. Pelo menos 25 qps a 12 MHz SPI ou 40 qps a 24 MHz;
- campo de jogo duplo com rolagem de paralaxe;
- tudo está escrito em C. Nenhum código assembler;
- Reconhecimento de colisões com precisão de pixels;
- sobreposição de tela.
Parece que alcançar esses objetivos é bastante difícil. É, especialmente se recusarmos o código no asm!
Por exemplo, com cores de 16 bits, um tamanho de tela de 160 × 128 pixels exigirá 40 KB para o buffer de tela, mas temos apenas 32 KB de RAM! E ainda precisamos de rolagem de paralaxe em um campo de jogo duplo e muito mais, com uma frequência de pelo menos 25/40 fps!
Mas nada é impossível para nós, certo?
Usamos truques e funções internas do ATSAMD21! Como "hardware", levamos o
uChip , que pode ser comprado na
Itaca Store .
uChip: o coração do nosso projeto!Tem as mesmas características do Arduino Zero, mas é muito menor e também mais barato que o Arduino Zero original (sim, você pode comprar um Arduino Zero falso por US $ 10 no AliExpress ... mas queremos aproveitar o original). Isso nos permitirá criar um pequeno console portátil. Você pode adaptar esse projeto para o Arduino Zero quase sem esforço, apenas o resultado terá um tamanho bastante complicado.
Também criamos uma pequena placa de teste que implementa um console portátil para os pobres. Detalhes abaixo!
Não usaremos a estrutura do Arduino. Não é adequado quando se trata de otimizar e gerenciar equipamentos. (E não vamos falar sobre o IDE!)
Neste artigo, descreveremos como chegamos à versão final do jogo, descreveremos todas as otimizações e critérios usados. O jogo em si ainda não está completo, não possui som, níveis, etc. No entanto, pode ser usado como ponto de partida para muitos tipos diferentes de jogos!
Além disso, existem muito mais opções de otimização, mesmo sem o assembler!
Então, vamos começar nossa jornada!
Dificuldades
De fato, o projeto tem dois aspectos complexos: tempos e memória (RAM e armazenamento).
A memória
Vamos começar com a memória. Primeiro, em vez de armazenar uma imagem de grande nível, usamos blocos. De fato, se você analisar cuidadosamente a maioria das plataformas, verá que elas são criadas a partir de um pequeno número de elementos gráficos (blocos) que são repetidos várias vezes.
Turrican 2 em Amiga. Um dos melhores jogos de plataforma de todos os tempos. Você pode ver facilmente as peças nele!O mundo / nível parece diverso graças a várias combinações de peças. Isso economiza muita memória na unidade, mas não resolve o problema de um enorme buffer de quadro.
O segundo truque que usamos é possível devido ao grande poder computacional do uC e à presença de DMA! Em vez de armazenar todos os dados do quadro na RAM (e por que isso é necessário?) Criaremos uma cena em todos os quadros do zero. Em particular, continuaremos a usar buffers, mas de forma que eles se encaixem em um bloco horizontal de gráficos de dados com uma altura de 16 pixels.
Tempos - CPU
Quando um engenheiro precisa criar algo, ele primeiro verifica se isso é possível. É claro que, no início, realizamos esse teste!
Portanto, precisamos de pelo menos 25 qps em uma tela de 160 × 128 pixels. Isso é 512.000 pixels / s. Como o microcontrolador opera com uma frequência de 48 MHz, temos pelo menos 93 ciclos de clock por pixel. Esse valor cai para 58 ciclos se apontarmos para 40 fps.
De fato, nosso microcontrolador é capaz de processar até 2 pixels por vez, porque cada pixel ocupa 16 bits e o ATSAMD21 possui um barramento interno de 32 bits, ou seja, o desempenho será ainda melhor!
Um valor de 93 ciclos de relógio nos diz que a tarefa é completamente viável! De fato, podemos concluir que a CPU sozinha pode lidar com todas as tarefas de renderização sem DMA. Provavelmente, isso é verdade, especialmente quando se trabalha com assembler. No entanto, o código será muito difícil de manipular. E em C tem que ser muito otimizado! Na verdade, o Cortex M0 + não é tão amigável para C quanto o Cortex M3 e não possui muitas instruções (nem carrega / salva com subsequente / preliminar incremento / decréscimo!), Que deve ser implementado com duas ou mais instruções simples.
Vamos ver o que precisamos fazer para desenhar dois campos de jogo (supondo que já conheçamos as coordenadas xey, etc.).
- Calcule a localização do pixel em primeiro plano na memória flash.
- Obtenha o valor do pixel.
- Se for transparente, calcule a posição do pixel de fundo no flash.
- Obtenha o valor do pixel.
- Calcule o local de destino.
- Salve o pixel no buffer.
Além disso, para cada sprite que possa entrar no buffer, as seguintes operações devem ser executadas:
- Calcule a posição de um pixel sprite na memória flash.
- Obtendo o valor do pixel.
- Se não for transparente, calcule o local do buffer de destino.
- Salvando um pixel no buffer.
Todas essas operações não são implementadas não apenas como uma única instrução ASM, mas cada instrução ASM requer dois ciclos ao acessar a RAM / memória flash.
Além disso, ainda não temos lógica de jogo (que, felizmente, leva uma pequena quantidade de tempo, porque é calculada uma vez por quadro), reconhecimento de colisão, processamento de buffer e instruções necessárias para o envio de dados via SPI.
Por exemplo, aqui está o pseudocódigo do que temos que fazer (por enquanto, assumimos que o jogo não tem rolagem e o campo de jogo tem um fundo de cor constante!) Somente para o primeiro plano.
Deixe cameraY e cameraX serem as coordenadas do canto superior esquerdo da tela no mundo do jogo.
Deixe xTilepos e yTilepos serem a posição do bloco atual no mapa.
xTilepos = cameraX / 16;
O número de instruções para 2560 pixels (160 x 16) é aproximadamente 16k, ou seja, 6 por pixel. De fato, você pode desenhar dois pixels por vez. Isso reduz pela metade o número real de instruções por pixel, ou seja, o número de instruções de alto nível por pixel é de aproximadamente 3. No entanto, algumas dessas instruções de alto nível serão divididas em duas ou mais instruções de montagem ou requerem pelo menos dois ciclos para serem concluídas porque eles acessam para a memória. Além disso, não consideramos redefinir o pipeline da CPU devido a saltos e estados de espera pela memória flash. Sim, ainda estamos longe dos ciclos 58-93 à nossa disposição, mas ainda precisamos levar em consideração o contexto do campo de jogo e dos sprites.
Embora vejamos que o problema pode ser resolvido em uma CPU, o DMA será muito mais rápido. O acesso direto à memória deixa ainda mais espaço para sprites de tela ou melhores efeitos gráficos (por exemplo, podemos implementar a mistura alfa).
Veremos que, para configurar o DMA para cada bloco, precisamos de instruções inferiores a 100 C, ou seja, inferiores a 0,5 por pixel! Obviamente, o DMA ainda precisará executar o mesmo número de transferências na memória, mas o incremento e a transmissão de endereços são realizados sem a intervenção da CPU, o que pode fazer outra coisa (por exemplo, calcular e renderizar sprites).
Usando o temporizador SysTick, descobrimos que o tempo necessário para preparar o DMA para todo o bloco e depois para completar o DMA é de aproximadamente 12k ciclos de clock. Nota: ciclos de relógio! Instruções não de alto nível! O número de ciclos é bastante alto para apenas 2560 pixels, ou seja, 1.280 palavras de 32 bits. De fato, temos cerca de 10 ciclos por palavra de 32 bits. No entanto, é necessário considerar o tempo necessário para preparar o DMA, bem como o tempo necessário para o DMA carregar descritores de transferência da RAM (que contêm essencialmente ponteiros e o número de bytes transferidos). Além disso, sempre há algum tipo de alteração no barramento de memória (para que a CPU não fique ociosa sem dados) e a memória flash requer pelo menos um estado de espera.
Horários - SPI
Outro gargalo é o SPI. 12 MHz é suficiente para 25 qps? A resposta é sim: 12 MHz corresponde a cerca de 36 quadros por segundo. Se usarmos 24 MHz, o limite dobrará!
A propósito, as especificações da tela e do microcontrolador dizem que a velocidade máxima do SPI é respectivamente 15 e 12 MHz. Testamos e garantimos que ele possa ser aumentado para 24 MHz sem problemas, pelo menos na "direção" necessária (o microcontrolador grava no visor).
Usaremos o popular monitor SPI de 1,8 polegadas. Garantimos que o ILI9163 e o ST7735 operem normalmente com uma frequência de 12 MHz (pelo menos com 12 MHz. Verifica-se que o ST7735 opera com uma frequência de até 24 MHz). Se você deseja usar a mesma tela do tutorial “Como reproduzir vídeos no Arduino Uno”, recomendamos que você a modifique caso queira adicionar suporte SD no futuro. Estamos usando a versão do cartão SD para ter muito espaço para outros elementos, como som ou níveis adicionais.
Gráficos
Como já mencionado, o jogo usa peças. Cada nível consistirá em blocos repetidos de acordo com a tabela, que chamamos de "gameMap". Qual será o tamanho de cada peça? O tamanho de cada bloco afeta muito o consumo de memória, detalhes e flexibilidade (e, como veremos mais adiante, também a velocidade). Ladrilhos muito grandes exigirão a criação de um novo ladrilho para cada pequena variação necessária. Isso ocupará muito espaço na unidade.
Dois blocos de 32 × 32 pixels de tamanho (esquerdo e central), que diferem em uma pequena parte (a parte superior direita do pixel é 16 × 16). Portanto, precisamos armazenar dois blocos diferentes com um tamanho de 32 × 32 pixels. Se usarmos um bloco de 16 × 16 pixels (à direita), precisamos armazenar apenas dois blocos de 16 × 16 (um bloco completamente branco e um bloco à direita). No entanto, ao usar blocos 16 × 16, obtemos 4 elementos do mapa.No entanto, são necessários menos blocos por tela, o que aumenta a velocidade (veja abaixo) e reduz o tamanho do mapa (ou seja, o número de linhas e colunas na tabela) de cada nível. Ladrilhos muito pequenos criam o problema oposto. As tabelas de mapas estão ficando maiores e a velocidade está ficando mais lenta. Obviamente, não tomaremos decisões estúpidas. por exemplo, selecione blocos com um tamanho de 17 × 31 pixels. Nosso amigo fiel - graus dois! O tamanho 16 × 16 é quase a “regra de ouro”, é usado em muitos jogos, e nós o escolhemos!
Nossa tela tem um tamanho de 160 × 128. Em outras palavras, precisamos de 10 × 8 blocos por tela, ou seja, 80 entradas na tabela. Para um grande nível de telas 10 × 10 (ou telas 100 × 1), apenas 8.000 registros serão necessários (16 KB se usarmos 16 bits para gravação. Mais tarde, mostraremos por que decidimos escolher 16 bits para gravação).
Compare isso com a quantidade de memória que provavelmente será ocupada por uma foto grande em toda a tela: 40 KB * 100 = 4 MB! Isso é loucura!
Vamos falar sobre o sistema de renderização.
Cada quadro deve conter (em ordem de desenho):
- gráficos de fundo (campo de jogo de volta)
- o próprio gráfico de nível (primeiro plano).
- sprites
- sobreposição de texto / parte superior.
Em particular, executaremos sequencialmente as seguintes operações:
- Desenho de fundo + primeiro plano (blocos)
- desenho de peças translúcidas + sprites + sobreposição superior
- enviando dados por SPI.
Ladrilhos de fundo e totalmente opacos serão desenhados pelo DMA. Um bloco totalmente opaco é um bloco no qual não há pixels transparentes.
Ladrilho parcialmente transparente (esquerda) e completamente opaco (direita). Em um bloco parcialmente transparente, alguns pixels (no canto inferior esquerdo) são transparentes e, portanto, um plano de fundo é visível nessa área.Ladrilhos, sprites e sobreposições parcialmente transparentes não podem ser renderizados com eficiência pelo DMA. De fato, o sistema DMA do chip ATSAMD21 simplesmente copia os dados e, ao contrário do Blitter do computador Amiga, ele não verifica a transparência (definida pelo valor da cor). Todos os elementos parcialmente transparentes são desenhados pela CPU.
Os dados são então transmitidos para o display usando DMA.
Criando um pipeline
Como você pode ver, se executarmos essas operações sequencialmente em um buffer, levará muito tempo. De fato, enquanto o DMA estiver em execução, a CPU não estará ocupada, exceto aguardando a conclusão do DMA! Essa é uma maneira ruim de implementar um mecanismo gráfico. Além disso, quando o DMA envia dados para um dispositivo SPI, ele não usa toda a largura de banda. De fato, mesmo quando o SPI opera com uma frequência de 24 MHz, os dados são transmitidos apenas com uma frequência de 3 MHz, o que é bastante pequeno. Em outras palavras, o DMA não está acostumado a todo o seu potencial: o DMA pode executar outras tarefas sem realmente perder o desempenho.
Por isso, implementamos o pipeline, que é o desenvolvimento da idéia de buffer duplo (usamos três buffers!). Obviamente, no final, as operações são sempre executadas seqüencialmente. Mas a CPU e o DMA simultaneamente executam tarefas diferentes, sem (especialmente) se afetando.
Aqui está o que acontece ao mesmo tempo:
- O buffer é usado para desenhar dados de segundo plano usando o canal DMA 1;
- Em outro buffer (anteriormente preenchido com dados de segundo plano), a CPU desenha sprites e blocos parcialmente transparentes;
- Em seguida, outro buffer (que contém o bloco de dados horizontal completo) é usado para enviar dados para o display via SPI usando o canal DMA 0. Obviamente, o buffer usado para enviar dados via SPI foi preenchido anteriormente com sprites enquanto o SPI enviava o bloco anterior e outro buffer preenchido com azulejos.
DMA
O sistema DMA do chip ATSAMD21 não é comparável ao Blitter, mas, no entanto, possui seus próprios recursos úteis. Graças ao DMA, podemos fornecer uma taxa de atualização muito alta, apesar de ter um campo de jogo duplo.
A configuração da transferência do DMA é armazenada na RAM, em “Descritores do DMA”, informando ao DMA como e onde deve ser realizada a transferência atual. Esses descritores podem ser unidos: se houver uma conexão (ou seja, não houver ponteiro nulo), depois que a transferência for concluída, o DMA receberá automaticamente o próximo descritor. Com o uso de vários descritores, o DMA pode executar "transferências complexas", que são úteis quando, por exemplo, o buffer de origem é uma sequência de segmentos não contíguos de bytes contíguos. No entanto, leva tempo para obter e escrever descritores, porque você precisa salvar / carregar 16 bytes de descritor da RAM.
O DMA pode trabalhar com dados de diferentes comprimentos: bytes, meias palavras (16 bits) e palavras (32 bits). Na especificação, esse comprimento é chamado de "tamanho da batida". Para o SPI, somos forçados a usar a transferência de bytes (embora a especificação REVD atual afirme que os chips ATSAMD21 SERCOM têm FIFO, que, de acordo com a Microchip, podem aceitar dados de 32 bits, de fato, parece que eles não têm FIFO. A especificação REVD também menciona O registro SERCOM CTRLC, que está ausente nos arquivos de cabeçalho e na seção de descrição do registro. Felizmente, ao contrário do AVR, o ATSAMD21 pelo menos possui um registro de dados de transmissão em buffer, portanto, não haverá pausas na transmissão!). Para desenhar peças, é claro que usamos 32 bits. Isso permite que você copie dois pixels por batida. O chip ATSAMD21 DMA também permite que cada batida de origem aumente o endereço de origem ou destino em um número fixo de tamanhos de batida.
Esses dois aspectos são muito importantes e determinam a maneira como desenhamos peças.
Primeiro, se renderizamos um pixel por batida (16 bits), reduziremos pela metade a taxa de transferência do nosso sistema. Não podemos recusar largura de banda completa!
No entanto, se desenharmos dois pixels por batida, o campo do jogo poderá rolar apenas um número par de pixels, o que causará movimentos suaves. Para lidar com isso, você pode usar um buffer que seja dois ou mais pixels maior. Ao enviar dados para a tela, usaremos o deslocamento correto (0 ou 1 pixel), dependendo se precisamos mover a “câmera” por um número par ou ímpar de pixels.
No entanto, por uma questão de simplicidade, reservamos espaço para 11 blocos completos (160 + 16 pixels) e não para 160 + 2 pixels. Essa abordagem tem uma grande vantagem: não precisamos calcular e atualizar o endereço do destinatário de cada descritor de DMA (isso exigiria várias instruções, o que poderia resultar em muitos cálculos por bloco). Obviamente, desenharemos apenas o número mínimo de pixels, ou seja, não mais que 162. Sim, no final, gastaremos um pouco de memória extra (levando em consideração três buffers, são cerca de 1500 bytes) para velocidade e simplicidade. Você também pode executar outras otimizações.

Todos os buffers de bloco de 16 linhas (sem descritores) são visíveis nesta animação GIF. À direita está o que é realmente exibido. Os primeiros 32 quadros são mostrados em GIF, nos quais movemos 1 pixel para a direita em cada quadro. A área preta do buffer é a parte que não é atualizada e seu conteúdo simplesmente permanece das operações anteriores. Quando a tela rola um número ímpar de quadros, uma área de 162 pixels de largura é desenhada no buffer. No entanto, a primeira e a última coluna delas (destacadas na animação) são descartadas. Quando o valor de rolagem é múltiplo de 16 pixels, as operações de desenho no buffer começam na primeira coluna (x = 0).
E a rolagem vertical?
Nós vamos lidar com isso depois que mostrarmos um método de armazenamento de blocos na memória flash.
Como armazenar peças
Uma abordagem ingênua (que nos serviria apenas se renderizássemos através da CPU) seria armazenar os blocos na memória flash como uma sequência de cores de pixels. O primeiro pixel da primeira linha, a segunda e assim por diante, até a décima sexta. Em seguida, salvamos o primeiro pixel da segunda linha, a segunda e assim por diante.
Por que essa decisão é ingênua? Como nesse caso, o DMA pode renderizar apenas 16 pixels por descritor de DMA! Portanto, precisaremos de 16 descritores, cada um dos quais precisa de 4 + 4 operações de acesso à memória (ou seja, para transferir 32 bytes - 8 operações de leitura na memória + 8 operações de gravação na memória - o DMA deve executar mais 4 leituras + 4 gravações). Isso é bastante ineficiente!
De fato, para cada descritor, o DMA pode incrementar apenas os endereços de origem e destino em um número fixo de palavras. Após copiar a primeira linha do bloco para o buffer, o endereço do destinatário não deve ser aumentado em 1 palavra, mas em um valor que aponte para a próxima linha do buffer. Isso não é possível porque cada descritor de transmissão indica apenas o incremento da transmissão por batida, que não pode ser alterado.
Será muito mais inteligente enviar os dois primeiros pixels de cada linha do bloco sequencialmente, ou seja, pixels 0 e 1 da linha 0, pixels 0 e 1 da linha 1, etc., para os pixels 0 e 1 da linha 15. Em seguida, enviamos os pixels 2 e 3 da linha 0 e assim por diante.
Como um bloco é armazenado?Na figura acima, cada número indica a ordem em que o pixel de 16 bits é armazenado na matriz de blocos.
Isso pode ser feito com um descritor, mas precisamos de duas coisas:
- Os ladrilhos devem ser armazenados para que, ao incrementar a fonte em uma palavra, sempre apontemos para as posições de pixel corretas. Em outras palavras, se (r, c) é um pixel na linha r e na coluna c, precisamos salvar os pixels (0,0) (0,1) (1,0) (1,1) (2,0) sequencialmente (2,1) ... (15,0) (15,1) (0,2) (0,3) (1,2) (1,3) ...
- O buffer deve ter 256 pixels de largura (não 160)
O primeiro objetivo é muito fácil de alcançar: basta alterar a ordem dos dados, você pode fazer isso ao exportar gráficos para um arquivo c (veja a imagem acima).
O segundo problema pode ser resolvido porque o DMA permite aumentar o endereço do destinatário após cada batida em 512 bytes. Isso tem duas consequências:
- Não podemos enviar dados usando um único descritor sobre um bloco SPI. Este não é um problema muito sério, porque no final lemos um descritor através de 160 pixels. O impacto no desempenho será mínimo.
- O bloco deve ter um tamanho de 256 * 2 * 16 bytes = 8 KB e haverá muito "espaço não utilizado" nele.
No entanto, esse espaço ainda pode ser usado, por exemplo, para descritores.
De fato, cada descritor tem 16 bytes de tamanho. Precisamos de pelo menos 10 * 8 (e na verdade 11 * 8!) Descritores para blocos e 16 descritores para SPI.
Por isso, quanto mais peças, maior a velocidade. De fato, se usássemos, por exemplo, um bloco de 32 x 32, precisaríamos de menos descritores por tela (320 em vez de 640). Isso reduziria o desperdício de recursos.
Exibir bloco de dados
O buffer do bloco, os descritores e outros dados são armazenados em um tipo de estrutura, que chamamos de displayBlock_t.
displayBlock é uma matriz de 16 elementos displayLineData_t. Os dados do DisplayLine contêm 176 pixels mais 80 palavras. Nessas 80 palavras, armazenamos descritores de exibição ou outros dados úteis de exibição (usando união).
Como temos 16 linhas, cada bloco na posição X usa os 8 primeiros descritores de DMA (0 a 7) das linhas X. Como temos um máximo de 11 blocos (a linha de exibição tem 176 pixels de largura), os blocos usam apenas os primeiros descritores de DMA 11 linhas de dados. Os descritores 8 a 9 de todas as linhas e os descritores 0 a 9 das linhas 11 a 15 são gratuitos.
Desses, os descritores 8 e 9 das linhas 0..7 serão utilizados para o SPI.
Os descritores 0..9 linhas 11 a 15 (até 50 descritores, embora usaremos apenas 48 deles) serão usados para o campo de jogo em segundo plano.
A figura abaixo mostra sua estrutura.
Campo de jogo em segundo plano
O campo de jogo em segundo plano é tratado de maneira diferente. Primeiro, se precisarmos de rolagem suave, teremos que retornar ao formato de dois pixels, porque o primeiro plano e o plano de fundo serão rolados em velocidades diferentes. Portanto, a batida será no meio. Embora isso seja uma desvantagem em termos de velocidade, essa abordagem facilita a integração. Temos apenas um pequeno número de descritores, portanto, pequenos blocos não podem ser usados. Além disso, para simplificar o trabalho e adicionar rapidamente paralaxe, usaremos longos "setores".
O plano de fundo é desenhado apenas se houver pelo menos um pixel parcialmente transparente. Isso significa que, se houver apenas um bloco transparente, o plano de fundo será desenhado. Obviamente, isso é um desperdício de largura de banda, mas simplifica tudo.
Compare o fundo e os campos de jogo da frente:
- Em segundo plano, são usados setores, que são blocos longos armazenados de maneira "ingênua".
- O plano de fundo tem seu próprio mapa, mas horizontalmente ele se repete. Graças a isso, menos memória é usada.
- O plano de fundo possui paralaxe para cada setor.
Campo de jogo dianteiro
Como foi dito, em cada bloco temos até 11 peças (10 peças completas ou 9 peças completas e 2 limas parciais). Cada um desses blocos, se não estiver marcado como transparente, é desenhado DMA. Se não for completamente opaco, será adicionado à lista, que será analisada posteriormente, ao renderizar sprites.
Conectamos dois campos de jogo
Os descritores do campo de jogo em segundo plano (que sempre são calculados) e do campo de jogo da frente formam uma lista vinculada muito longa. A primeira parte desenha um campo de jogo em segundo plano. A segunda parte desenha blocos sobre o fundo. O comprimento da segunda parte pode ser variável, porque os descritores de DMA de blocos parcialmente transparentes são excluídos da lista. Se o bloco contiver apenas blocos opacos, o DMA será configurado da seguinte maneira. para iniciar diretamente a partir do primeiro descritor do primeiro bloco.
Sprites e azulejos com transparência
Azulejos com transparência e sprites são processados quase da mesma forma. A análise de pixel lado a lado / sprite é realizada.
Se for preto, será transparente e, portanto, o bloco de plano de fundo não será alterado. Se não for preto, o pixel do plano de fundo será substituído por um pixel de sprite / lado a lado.Rolagem vertical
Ao trabalhar com rolagem horizontal, desenhamos até 11 blocos, mesmo que ao desenhar 11 blocos, o primeiro e o último sejam apenas parcialmente desenhados. Essa renderização parcial é possível devido ao fato de que cada descritor desenha duas colunas do bloco, para que possamos definir facilmente o início e o fim da lista vinculada.Ao trabalhar com rolagem vertical, precisamos calcular o registro do receptor e o volume de transmissão. Eles devem ser definidos várias vezes por quadro. Para evitar esse barulho, podemos simplesmente desenhar até 9 blocos completos por quadro (8 se a rolagem for um múltiplo de 16).Equipamento
Como dissemos, o coração do sistema é o uChip. E o resto?Aqui está um diagrama! Alguns aspectos merecem ser mencionados.Chaves
Para otimizar o uso de E / S, usamos um pequeno truque. Teremos 4 barramentos sensores L1-L4 e um fio LC comum. 1 e 0. são aplicados alternadamente ao fio comum, de modo que os barramentos dos sensores serão alternadamente puxados para baixo ou para cima com a ajuda de resistores de tração internos. Duas chaves são conectadas entre cada um dos barramentos principais e um barramento comum. Um diodo é inserido em série com essas duas teclas. Cada um desses diodos é alternado na direção oposta, para que cada vez que apenas uma tecla seja "lida".Como não existe um controlador de teclado interno (e nenhum controlador de teclado interno usa esse método interessante), oito teclas são pesquisadas rapidamente no início de cada quadro. Como as entradas devem ser puxadas para cima e para baixo, não podemos (e não queremos) usar resistores externos; portanto, precisamos usar resistores integrados, que podem ter uma resistência bastante alta (60 kOhm). Isso significa que quando o barramento comum muda de estado e os barramentos de dados alteram seu estado de ativação / desativação, é necessário inserir algum atraso para que o resistor de ativação / desativação interno altere o contrato e defina a capacitância perdida para o nível desejado. Mas não queremos esperar! Portanto, colocamos o barramento comum em um estado de alta impedância (para que não haja discordância) e primeiro alteramos os barramentos do sensor para os valores lógicos 1 ou 0,configurando-os temporariamente como saída. Mais tarde, eles são configurados como entrada puxando para cima ou para baixo. Como a resistência de saída é da ordem de dezenas de Ohms, o estado muda em alguns nanossegundos, ou seja, quando o barramento do sensor retorna à entrada, ele já estará no estado desejado. Depois disso, o barramento comum muda para a saída com a polaridade oposta.Isso melhora muito a velocidade da digitalização e elimina a necessidade de não atrasos / instruções.Conexão SPI
Conectamos o SD e a tela para que eles se comuniquem sem transferir dados para o ATSAMD21. Isso pode ser útil se você quiser reproduzir o vídeo.Os resistores que conectam o MISO e o MOSI devem estar baixos. Se eles forem muito grandes, o SPI não funcionará, porque o sinal será muito fraco.Otimização e desenvolvimento adicional
Um dos maiores problemas é o uso de RAM. Três blocos ocupam 8 KB cada, deixando apenas 8 KB por pilha e outras variáveis. No momento, temos apenas 1,3 KB de RAM livre + 4 KB de pilha (4 KB por pilha - isso é muito, talvez reduzamos).No entanto, você pode usar blocos com uma altura de não 16, mas 8 pixels. Isso aumentará o desperdício de recursos nos descritores de DMA, mas quase reduzirá pela metade a quantidade de memória ocupada pelo buffer do bloco (observe que o número de descritores não será alterado se continuarmos a usar blocos 16 × 16, portanto, teremos que alterar a estrutura do bloco). Isso pode liberar aproximadamente 7,5 KB de RAM, o que será muito útil para implementar funções como uma placa modificável com segredos ou adicionar som (embora o som possa ser adicionado mesmo com 1 KB de RAM).Outro problema é o sprite, mas essa modificação é muito mais simples de executar e você só precisa da função createNextFrameScene (). De fato, estamos criando na RAM uma enorme variedade com o estado de todos os sprites. Em seguida, para cada sprite, calculamos se sua posição está dentro da área da tela e, em seguida, a animamos e a adicionamos à lista de renderização.Em vez disso, você pode executar a otimização. Por exemplo, no gameMap, você pode armazenar não apenas o valor do bloco, mas também um sinalizador indicando a transparência do bloco, definido no editor. Isso nos permitirá verificar rapidamente se o bloco deve ser renderizado: DMA ou CPU. Por isso, usamos registros de 16 bits para o cartão lado a lado. Se assumirmos que temos um conjunto de 256 blocos (no momento, temos menos de 128 blocos, mas há espaço suficiente na memória flash para adicionar novos), existem 7 bits livres que podem ser usados para outros fins. Três desses sete bits podem ser usados para indicar se um sprite / objeto está sendo armazenado. Por exemplo:
0b000 =
0b001 =
0b010 =
0b011 =
0b100 =
0b101 =
0b110 =
0b111 = , , .
Depois, você pode criar uma tabela de bits na RAM, na qual cada bit significa se (por exemplo, um inimigo) é detectado / se (por exemplo, um bônus) é captado / se um determinado objeto é ativado (alternar). Em um nível de telas 10 × 10, isso exigirá 8000 bits, ou seja, 1 KB de RAM. O bit é redefinido quando um inimigo é detectado ou um bônus é recebido.Em createNextFrameScene (), devemos verificar os bits correspondentes aos blocos na área visível atual. Se eles tiverem o valor 1:- Se isso é um bônus, basta adicioná-lo à lista de sprites para renderização.
- Se este for um inimigo, crie um sprite dinâmico e redefina a bandeira. No próximo quadro, a cena conterá um sprite dinâmico até que o inimigo saia da tela ou seja morto.
Essa abordagem tem desvantagens.- -, ( ). .
- -, 80 , , . , 32 . , «/» ( «», .. 0!). «», «» ( ).
- -, . ( ), . , .
- -, , , , . , , . , , , , !
- , (, Unreal Tournament , ).
No entanto, dessa maneira, podemos armazenar e processar sprites em um nível muito mais eficiente.No entanto, essa técnica é mais relevante para a "lógica do jogo" do que para o mecanismo gráfico do jogo.Talvez no futuro implementemos esta função.Resumir
Esperamos que você tenha gostado deste artigo introdutório. Precisamos explicar muitos outros aspectos que serão os tópicos de futuros artigos.Enquanto isso, você pode baixar o código fonte completo do jogo! Se você gosta, pode apoiar financeiramente o artista ansimuz , que desenhou todos os gráficos e deu ao mundo gratuitamente. Também aceitamos doações .O jogo ainda não terminou. Queremos adicionar som, muitos níveis, objetos com os quais você pode interagir e coisas do gênero. Você pode criar suas próprias modificações! Esperamos ver novos jogos com novos gráficos e níveis!Em breve lançaremos um editor de mapas, mas por enquanto é muito rudimentar mostrá-lo à comunidade!Vídeo
(Nota: devido à pouca iluminação, o vídeo foi gravado com uma taxa de quadros muito mais baixa! Em breve atualizaremos o vídeo para que você possa estimar a velocidade total em 40 fps!)Gratidão
Os gráficos do jogo (e os blocos mostrados em algumas imagens) são retirados do ativo gratuito "Sunny Land" criado por ansimuz .Materiais para download
O código fonte do projeto é de domínio público, ou seja, é fornecido gratuitamente. Compartilhamos isso na esperança de que seja útil para alguém. Não garantimos que, devido a algum bug / erro no código, não haverá problemas!Diagrama esquemático do projetoKiCadProjeto Atmel Studio 7 (fonte)