Gostaria de falar sobre o funcionamento do console da Nintendo DS GPU, suas diferenças em relação às GPUs modernas, e também expressar minha opinião sobre por que usar o Vulkan em vez do OpenGL em emuladores não trará vantagens.
Eu realmente não conheço o Vulkan, mas pelo que li, está claro para mim que o Vulkan difere do OpenGL por funcionar em um nível mais baixo, permitindo que os programadores gerenciem a memória da GPU e coisas semelhantes. Isso pode ser útil para emular consoles mais modernos que usam APIs gráficas proprietárias que fornecem níveis de controle não disponíveis no OpenGL.
Por exemplo, o renderizador de hardware blargSNES - um de seus truques é que, durante algumas operações com buffers de cores diferentes, um buffer de profundidade / estêncil é usado. No OpenGL, isso não é possível.
Além disso, resta menos lixo entre o aplicativo e a GPU, o que significa que, se implementado corretamente, o desempenho será maior. Enquanto os drivers OpenGL estão cheios de otimizações para casos de uso padrão e até para jogos específicos, no Vulkan, o aplicativo em si deve ser bem escrito antes de tudo.
Isto é, em essência, "grande responsabilidade vem com grande força".
Não sou especialista em API 3D, então vamos voltar a isso. O que eu sei bem: console GPU DS.
Vários artigos já foram escritos sobre suas partes individuais (
sobre seus quads sofisticados ,
sobre absurdos com viewport ,
sobre os recursos divertidos do rasterizador e
sobre a incrível implementação do anti-aliasing ), mas neste artigo consideraremos o dispositivo como um todo, mas com todos os detalhes interessantes. Pelo menos é tudo o que sabemos.
A GPU em si é um hardware bastante antigo e obsoleto. É limitado a 2048 polígonos e / ou 6144 vértices por quadro. A resolução é 256x192. Mesmo se você quadruplicar isso, o desempenho não será um problema. Sob condições ideais, o DS pode gerar até 122880 polígonos por segundo, o que é ridículo pelos padrões das GPUs modernas.
Agora vamos aos detalhes da GPU. Aparentemente, parece bastante padrão, mas no fundo seu trabalho é muito diferente do trabalho das GPUs modernas, o que torna a emulação de algumas funções mais complicada.
A GPU é dividida em duas partes: um mecanismo de geometria e um mecanismo de renderização. O mecanismo de geometria processa os vértices resultantes, constrói polígonos e os transforma para que você possa transmiti-los ao mecanismo de renderização, que (você adivinhou) desenha tudo na tela.
Mecanismo de geometria
Transportador geométrico bastante padrão.
Vale ressaltar que toda aritmética é realizada em números inteiros de ponto fixo, porque o DS não suporta números de ponto flutuante.
O mecanismo de geometria é emulado completamente por meio de programação (GPU3D.cpp), ou seja, não se aplica muito ao que usamos para renderizar gráficos, mas, de qualquer forma, vou lhe contar mais sobre isso.
1. Transformação e iluminação. Os vértices e coordenadas de textura resultantes são convertidos usando conjuntos de matrizes 4x4. Além das cores dos vértices, a iluminação é aplicada. Tudo é bastante padrão aqui, o único não-padrão é como as coordenadas de textura funcionam (1,0 = um DS texel). Também vale a pena mencionar todo o sistema de pilhas de matrizes, que em um grau ou outro são a implementação de hardware do glPushMatrix ().
2. Configurando polígonos. Os vértices convertidos são montados em polígonos, que podem ser triângulos, quadrângulos (quadris), faixas de triângulos ou faixas de quadrângulos. Os quads são processados nativamente e não se convertem em triângulos, o que é bastante problemático porque as GPUs modernas suportam apenas triângulos. No entanto, parece que alguém
veio com uma solução que eu preciso testar.
3. Solte. Os polígonos podem ser descartados dependendo da orientação na tela e do modo de seleção selecionado. Também esquema bastante padrão. No entanto, eu preciso descobrir como isso funciona para quads.
4. Truncamento. Os polígonos além do escopo de visibilidade são eliminados. Polígonos que se estendem parcialmente além dessa região são truncados. Esta etapa não cria novos polígonos, mas adiciona vértices aos já existentes. De fato, cada um dos 6 planos de truncamento pode adicionar um vértice ao polígono, ou seja, como resultado, podemos obter até 10 vértices. Na seção sobre o mecanismo de renderização, mostrarei como lidamos com isso.
5. Converta na viewport. As coordenadas X / Y são convertidas em coordenadas da tela. As coordenadas Z são convertidas para caber em um intervalo de buffer de profundidade de 24 bits.
O interessante é como as coordenadas W são processadas: elas são "normalizadas" para caber em um intervalo de 16 bits. Para isso, cada coordenada W do polígono é obtida e, se for maior que 0xFFFF, é deslocada para a direita em 4 posições para caber em 16 bits. Por outro lado, se a coordenada for menor que 0x1000, ela se moverá para a esquerda até cair no intervalo. Suponho que isso seja necessário para obter bons intervalos, o que significa maior precisão durante a interpolação.
6. Classificação. Os polígonos são classificados de modo que os polígonos translúcidos sejam desenhados primeiro. Em seguida, eles são classificados por suas coordenadas Y (sim), o que é necessário para polígonos opacos e opcionalmente translúcidos.
Além disso, esta é a razão da restrição de 2048 polígonos: para a classificação, eles precisam ser armazenados em algum lugar. Existem dois bancos de memória interna alocados para armazenar polígonos e vértices. Existe até um registro informando quantos polígonos e vértices são armazenados.
Mecanismo de renderização
E aqui começa a diversão!
Depois que todos os polígonos foram configurados e classificados, o mecanismo de renderização começa a funcionar.
A primeira coisa engraçada é como ela preenche os polígonos. Isso é completamente diferente do trabalho das GPUs modernas que executam o preenchimento de blocos e usam algoritmos otimizados por triângulo. Não sei como eles funcionam, mas vi como isso é feito na GPU do console do 3DS e tudo é baseado em blocos lá.
Seja como for, no DS, a renderização é feita em strings raster. Os desenvolvedores tiveram que fazer isso para que a renderização pudesse ser executada em paralelo com os motores bidimensionais da telha da velha escola, que executam desenhos em linhas raster. Há um pequeno buffer com 48 linhas raster que podem ser usadas para ajustar algumas linhas raster.
Um rasterizador é um renderizador de polígonos convexos com base em seqüências de caracteres raster. Ele pode lidar com um número arbitrário de vértices. Ele pode renderizar incorretamente se você passar polígonos que não são convexos ou têm arestas que se cruzam, por exemplo:
O polígono é uma borboleta. Tudo está correto e magnífico.Mas e se mudarmos isso?
Ai.Qual é o erro aqui? Vamos desenhar o contorno do polígono original para descobrir:
Um renderizador pode preencher apenas uma lacuna por linha raster. Ele define as arestas esquerda e direita começando nos picos mais altos e segue essas arestas até encontrar novos picos.
Na imagem mostrada acima, ele começa no vértice superior, ou seja, no canto superior esquerdo, e continua a ser preenchido até atingir o final da borda esquerda (vértice inferior esquerdo). Ele não sabe que as arestas se cruzam.
Nesse ponto, ele procura o próximo vértice na borda esquerda. É interessante notar que ele sabe que não precisa usar vértices mais altos que o atual e também sabe que as arestas esquerda e direita foram trocadas. Portanto, ele continua sendo preenchido até o final do aterro.
Eu acrescentaria mais alguns exemplos de polígonos não convexos, mas nos afastaremos muito do tópico.
Vamos entender melhor como o sombreamento e a textura do Gouraud funcionam com um número arbitrário de vértices. Existem algoritmos baricêntricos usados para interpolar dados ao longo de um triângulo, mas ... no nosso caso, eles não são adequados.
O renderizador do DS aqui também tem sua própria implementação. Mais algumas imagens interessantes.
Os vértices do polígono são os pontos 1, 2, 3 e 4. Os números não correspondem à ordem real de travessia, mas você entende o significado.
Na linha de varredura atual, o renderizador define os vértices diretamente ao redor das arestas (como mencionado acima, começa nos vértices mais altos e depois percorre as arestas até que estejam completas). No nosso caso, esses são os vértices 1 e 2 para a borda esquerda, 3 e 4 para a borda direita.
As inclinações das arestas são usadas para determinar os limites da folga, ou seja, pontos 5 e 6. Nesses pontos, os atributos dos vértices são interpolados com base nas posições verticais nas arestas (ou nas posições horizontais das arestas, cujas inclinações estão principalmente ao longo do eixo X).
Em seguida, para cada pixel do espaço (por exemplo, para o ponto 7), os atributos baseados na posição X dentro do espaço são interpolados dos atributos calculados anteriormente nos pontos 5 e 6.
Aqui, todos os coeficientes utilizados são iguais a 50% para simplificar o trabalho, mas o significado é claro.
Não entrarei nos detalhes da interpolação de atributos, embora também seja interessante escrever sobre isso. De fato, essa interpolação está correta do ponto de vista da perspectiva, mas possui simplificações e recursos interessantes.
Agora vamos falar sobre como o DS preenche os polígonos.
Quais regras de preenchimento ele usa? Também há muitas coisas interessantes aqui!
Em primeiro lugar, existem regras de preenchimento diferentes para polígonos opacos e translúcidos. Mas o mais importante é que essas regras se aplicam
pixel por pixel . Os polígonos translúcidos podem ter pixels opacos e seguirão as mesmas regras que os polígonos opacos. Você pode adivinhar que para imitar esses truques nas GPUs modernas, são necessárias várias passagens de renderização.
Além disso, diferentes atributos de polígono podem influenciar a renderização de várias maneiras interessantes. Além dos buffers de cor e profundidade razoavelmente padrão, o renderizador também possui
um buffer de atributo que rastreia todo tipo de coisas interessantes. Ou seja: o ID do polígono (separadamente para polígonos opacos e translúcidos), translucidez de pixel, a necessidade de aplicar neblina, se esse polígono é direcionado para ou a partir da câmera (sim, isso também) e se o pixel está na borda do polígono. E talvez outra coisa.
A tarefa de emular tal sistema não será trivial. Uma GPU moderna comum possui um buffer de estêncil limitado a 8 bits, o que está longe de ser suficiente para tudo o que pode armazenar um buffer de atributo. Precisamos apresentar uma solução complicada.
Vamos descobrir isso:
* Atualização do buffer de profundidade: necessária para pixels opacos, opcional para pixels translúcidos.
* IDs de polígono: IDs de 6 bits são atribuídos a polígonos, que podem ser usados para diversas finalidades. IDs de polígono opacos são usados para marcar bordas. O ID dos polígonos translúcidos pode ser usado para controlar onde eles serão desenhados: um pixel translúcido não será desenhado se o ID do polígono corresponder ao ID do polígono translúcido já no buffer do atributo. Além disso, os dois IDs de polígono também são usados para controlar a renderização de sombra. Por exemplo, você pode criar uma sombra que cubra o chão, mas não o personagem.
(Nota: as sombras são apenas uma implementação do buffer de estêncil, não há nada de terrível aqui.)
É importante notar que, ao renderizar pixels translúcidos, o ID existente do polígono opaco é salvo, bem como as sinalizações de borda do último polígono opaco.
* sinalizador de nevoeiro: determina se é necessário aplicar um passe de nevoeiro para esse pixel. O processo de atualização depende se o pixel recebido é opaco ou translúcido.
* bandeira da linha de frente: aqui há problemas com ela. Dê uma olhada na captura de tela:
Sands of Destruction, as telas deste jogo são um conjunto de truques. Eles não apenas alteram suas coordenadas Y para afetar a classificação em Y. A tela mostrada nesta captura de tela é provavelmente a pior.
Ele usa o caso limite do teste de profundidade: a função de comparação "menor que"
assume valores iguais se o jogo
desenha um polígono olhando para a câmera no topo dos pixels opacos do polígono direcionados para longe da câmera . Sim exatamente. E os valores Z de todos os polígonos são zero. Se você não emular esse recurso, alguns elementos estarão ausentes na tela.
Eu acho que isso foi feito para que a parte da frente do objeto estivesse sempre visível na parte de trás, mesmo quando elas são tão planas que os valores de Z são os mesmos. Com todos esses truques e truques, o renderizador do DS é semelhante à versão de hardware dos renderizadores da era do DOS.
Seja como for, emular esse comportamento por meio da GPU foi difícil. Mas existem outros casos semelhantes de teste de profundidade, que também precisam ser testados e documentados.
* sinalizadores de nervuras: o renderizador rastreia a localização das bordas dos polígonos. Eles são usados nas últimas passagens, nomeadamente na marcação de arestas e suavização de serrilhado. Também existem regras especiais para o preenchimento de polígonos opacos com o anti-aliasing desativado. O diagrama abaixo ilustra estas regras:
Nota: os wireframes são renderizados preenchendo apenas as arestas! Movimento muito inteligente.
Outra observação divertida sobre o buffer de profundidade:
Existem dois modos de buffer de profundidade possíveis no DS: buffer Z e buffer W. Isso parece ser bastante padrão, mas apenas se você não entrar em detalhes.
* O buffer Z usa coordenadas Z convertidas para caber em um intervalo de buffer de profundidade de 24 bits. As coordenadas Z são interpoladas linearmente sobre polígonos (com algumas esquisitices, mas não são particularmente importantes). Também não há nada fora do padrão.
* No buffer W, as coordenadas W são usadas "como estão". As GPUs modernas geralmente usam 1 / W, mas o DS usa apenas aritmética de ponto fixo; portanto, o uso de valores recíprocos não é muito conveniente. Seja como for, neste modo, as coordenadas W são interpoladas com correção de perspectiva.
Aqui está a aparência da aprovação final:
* marcação de borda: os pixels com marcadores de borda definidos recebem uma cor retirada da tabela e determinada com base no ID de um polígono opaco.
Eles serão bordas coloridas de polígonos. É importante notar que, se um polígono translúcido for desenhado sobre um polígono opaco, as bordas do polígono ainda serão coloridas.
Um efeito colateral do princípio de truncamento: as bordas nas quais os polígonos se cruzam com as bordas da tela também serão coloridas. Você pode, por exemplo, perceber isso nas capturas de tela do Picross 3D.
* nevoeiro: é aplicado a cada pixel com base nos valores de profundidade usados para indexar a tabela de densidade de nevoeiro. Como você pode imaginar, isso se aplica aos pixels que possuem sinalizadores de névoa configurados no buffer de atributo.
* antialiasing (suavização): é aplicado nas bordas dos polígonos (opacos). Com base nas inclinações das arestas ao renderizar polígonos, os valores de cobertura de pixel são calculados. Na última passagem, esses pixels são misturados com os pixels abaixo deles, usando o mecanismo complicado que eu descrevi em uma postagem anterior.
O antialiasing não deve (e não pode) ser emulado dessa maneira na GPU; portanto, isso não é importante aqui.
Exceto que, se a marcação de borda e o anti-aliasing devem ser aplicados aos mesmos pixels, eles obtêm apenas o tamanho da borda, mas com 50% de opacidade.
Parece que descrevi o processo de renderização mais ou menos bem. Não nos aprofundamos na mistura de texturas (combinando cores de vértice e textura), mas ela pode ser emulada em um shader de fragmento. O mesmo se aplica à marcação de borda e neblina, desde que encontremos uma maneira de contornar todo esse sistema com um buffer de atributo.
Mas, em geral, eu queria transmitir o seguinte: OpenGL ou Vulkan (assim como Direct3D, Glide ou qualquer outra coisa) não ajudarão aqui. Nossas GPUs modernas têm energia mais que suficiente para trabalhar com polígonos brutos. O problema são os detalhes e os recursos da rasterização. E nem se trata da idealidade dos pixels, por exemplo, basta olhar para o rastreador de problemas do emulador DeSmuME para entender quais problemas os desenvolvedores encontram ao renderizar através do OpenGL. Também temos que lidar com esses mesmos problemas de alguma forma.
Também observo que o uso do OpenGL nos permitirá portar o emulador, por exemplo, para o Switch (porque um usuário do Github chamado Hydr8gon começou a criar uma
porta para o emulador no Switch ).
Então ... me deseje sorte.