Este documento é um tutorial para manipular a renderização de um jogo (geralmente para aumentar sua qualidade) se você tiver apenas um binário disponível. Se você já se perguntou como algo como DSFIX, ou DPFIX, ou muitos dos meus plugins Gedosato funcionam, então isso é para você.
Se você já pensou que seria ótimo se você pudesse fazer algo assim também, então melhor ainda, isso é para você. Espero que economize muito tempo para descobrir coisas que se tornam uma segunda natureza quando você faz isso há meia década.
Existem três seções principais neste documento: " Preâmbulo " (o que você está lendo agora), " Análise " e " Manipulação ". "Análise" lida com a descoberta do que o jogo faz e o que provavelmente precisamos mudar, enquanto a "manipulação" explica como aplicar as mudanças reais.
Neste tutorial, usaremos este software:
Em termos de conhecimento fundamental, para obter todo o benefício deste tutorial, provavelmente seria bom ter:
Provavelmente ainda é um pouco útil sem alguns deles.
Vamos lidar com Nier: Automata , porque é um ótimo jogo e porque oferece o que eu consideraria uma quantidade "moderada" de desafio para os tipos de tarefas que desejamos executar. Ele também joga bem com o renderdoc sem qualquer persuasão complicada. Obviamente, o tutorial deve ser igualmente aplicável a muitos outros jogos.
Para fins de um tutorial (e para qualquer tipo de trabalho e modificação realmente), é importante ter um objetivo muito claro. Nosso objetivo para este exercício é aumentar a resolução espacial de renderização do efeito de oclusão ambiente em Nier: Automata. Cheguei a esse objetivo (como a maioria dos meus objetivos de modificação) jogando o jogo e vendo algo que eu não gostei, de qualidade de imagem.
Para entender como o jogo executa sua renderização, nós o executaremos no RenderDoc e capturará um quadro . Isso nos permitirá investigar tudo o que acontece, que está relacionado à renderização desse quadro específico. Para fazer isso, precisamos apontar renderdoc no jogo executável e lançá -lo:
Então, no jogo, passamos para um local que deve ser um bom lugar para julgar o efeito que queremos manipular e capturar um quadro pressionando F12 (o RenderDoc deve mostrar uma sobreposição no jogo que nos informa sobre este atalho). Depois de sair do jogo, o RenderDoc carregará e nos mostrará automaticamente o FRAMDUMP no modo Replay:
Não vou dar uma explicação completa de todos os elementos da interface do usuário do renderdoc. O que é importante para nós agora é que, à esquerda, o navegador de eventos fornece uma linha do tempo cronológica de todos os eventos de renderização e que o visualizador de textura nos permite ver a saída de cada chamada de renderização. Ao abrir o visualizador de textura e navegar para baixo nos eventos, devemos alcançar algo que parece um buffer de oclusão ambiente cru. É mostrado na captura de tela acima. O que vemos imediatamente é que é um buffer de 800x450 , apesar de nossa resolução de renderizar ser um 2560x1440 completo. É provavelmente o principal culpado para a baixa qualidade que estamos vendo, e é o que precisamos mudar.
No entanto, para entender tudo o que precisamos mudar, também precisamos saber quais entradas esse AO Pass usa. Felizmente, o RenderDoc facilita isso:
Mudamos para a guia Entradas à direita e agora vemos que só temos uma única entrada. Assim como a nossa saída, é 800 por 450. Observe que inicialmente seria mostrado apenas como uma área preta plana. Geralmente, esse é o caso dos buffers de ponto flutuante, se seus valores não caírem nos intervalos, podemos distinguir com o olho nu. Como você pode ver na parte destacada no topo, podemos manipular a configuração do intervalo no RenderDoc para tornar o conteúdo visível. Nesse caso, parece claramente um buffer z (armazenando um valor de profundidade para cada pixel), que é o que esperaríamos como entrada mínima para um passe SSAO.
Curiosamente, também vemos (na parte inferior) que é uma textura com 10 MIPMAPs. Uma investigação mais aprofundada dos passes de renderização usando -a como fonte ou alvo revela que os mapas MIP individuais são preenchidos imediatamente antes do passe AO. Aqui ajuda muito a ter lido o papel obscuro ambiental escalável, pois explica como um buffer Z hierárquico pode ser usado para acelerar bastante e reduzir a sobrecarga de memória de cálculos de AO em larga escala.
Olhando para as chamadas de renderização logo após o passe inicial do AO, vemos um passe de desfoque horizontal e vertical dependente da profundidade, o que também é típico da maioria das implementações da SSAO.
Para resumir, nossa análise inicial nos diz que:
R8G8B8A8_UNORM e armazena a cobertura AO, o outro é do formato R32_FLOAT com MIPMaps e armazena um tampão Z hierárquico.Vamos nos referir a essa captura de quadro RenderDoc como nossa referência inicial , que podemos usar para procurar o que realmente deveria estar acontecendo, caso algo dê errado (e sempre faça) quando começarmos a manipular as coisas.
Para manipular a renderização de um jogo, precisamos executar nosso código em seu processo e precisamos interceptar e potencialmente alterar suas chamadas de API 3D. Este é um tópico enorme e trabalhista e, como este tutorial se concentra em como entender e mudar o processo de renderização, em vez de técnicos da injeção e conexão da DLL, não irei mais longe nisso.
O que faremos para nossos experimentos é simplesmente usar o RenderDoc como nosso veículo de injeção. Isso tem a vantagem de ser uma ferramenta realmente sólida e bem testada, o que nos permite focar em descobrir o que precisamos fazer, e não por que nosso conjunto de ferramentas não está funcionando para algum caso de canto. Obviamente, para realmente criar uma versão significativamente distribuível e jogável, precisamos portar nosso resultado final para alguma estrutura de injeção projetada para esse fim.
A primeira coisa que precisamos fazer para aumentar a resolução de renderização é realmente tornar nossos buffers grandes o suficiente para apoiar essa resolução. Para fazer isso, alteramos o método WrappedID3D11Device::CreateTexture2D nas fontes renderdoc:
HRESULT WrappedID3D11Device::CreateTexture2D ( const D3D11_TEXTURE2D_DESC *pDesc,
const D3D11_SUBRESOURCE_DATA *pInitialData,
ID3D11Texture2D **ppTexture2D)
{
// Desired AO resolution
static UINT aoW = 1280 ;
static UINT aoH = 720 ;
// 800x450 R8G8B8A8_UNORM is the buffer used to store the AO result and subsequently blur it
// 800x450 R32_FLOAT is used to store hierarchical Z information (individual mipmap levels are rendered to)
// and serves as input to the main AO pass
if (pDesc-> Format == DXGI_FORMAT_R8G8B8A8_UNORM || pDesc-> Format == DXGI_FORMAT_R32_FLOAT) {
if (pDesc-> Width == 800 && pDesc-> Height == 450 ) {
// set to our display resolution instead
D3D11_TEXTURE2D_DESC copy = *pDesc;
copy. Width = aoW;
copy. Height = aoH;
pDesc = ©
}
}
// [...]
} O código é bastante auto-explicativo, a parte [...] refere-se à implementação renderDoc existente do método.
Normalmente, neste momento, eu também examinava imediatamente outras coisas relevantes e relacionadas que precisam ser adaptadas, como viewports, retangenas de tesoura e parâmetros de shader. No entanto, para o tutorial, é instrutivo ver o que acontece apenas com essa mudança:

Como você pode ver, o que acontece é que nossa renderização quebra completamente. Provavelmente, isso sempre será um resultado comum enquanto você estiver desenvolvendo algo assim. Então, como lidamos com isso? É aqui que entra nossa captura de quadros de referência e se mostra muito útil.
Tomando outra captura e comparando-a lado a lado com a nossa referência, podemos avançar até ver um problema ocorrendo. Geralmente, será bastante óbvio e, portanto, é neste caso: 
Como você pode ver, apenas uma pequena parte do nosso buffer manipulada está sendo usada pela GPU. O que você não pode ver na captura de tela, mas o que é óbvio ao inspecionar as chamadas de desenho subsequente no arquivo de rastreamento é que esse erro se propaga no mapa MIP individual passa até que basicamente nada seja deixado.
Se você estiver acostumado a esses tipos de modificação - ou depois de ler este tutorial;) - você sabe que essa é uma manifestação muito típica de uma configuração de viewport e/ou parâmetros de shader não sendo ajustados corretamente para corresponder aos novos tamanhos de buffer maior.
O que precisamos investigar são as configurações de viewport para as chamadas de desenho relevantes, bem como se existem parâmetros de shader que parecem ser derivados ou dependentes da resolução do buffer. Encontramos o seguinte para o passe principal da AO:
Vemos uma configuração de viewport de 800x450 com código de codificação, bem como um buffer constante de shader de pixel muito suspeito usado nesta chamada de desenho. Para interpretar os dois primeiros valores, alguns antecedentes gráficos e/ou experiência são novamente extremamente úteis. Eu imediatamente suspeitava que eles tivessem tamanhos de pixel na resolução original do buffer de entrada - eles são frequentemente fornecidos aos shaders para que possam acessar os valores da vizinhança. E, de fato, 1.0/1600 = 0.000625 , e o mesmo vale para a resolução vertical no segundo componente.
Para todos os outros passes envolvidos nesse caso em particular, a situação é muito semelhante - tanto a viewport quanto o parâmetro do shader relacionado precisam ser ajustados, e não vou entrar em detalhes para todos eles. Para os passes Z hierárquicos, é particularmente importante garantir que o nível MIP correto que está sendo usado como um renderStarget seja identificado, pois afeta a configuração do parâmetro.
Na verdade, ajustar as viewports e, em particular, os parâmetros do shader é um pouco complicado. Existem várias maneiras de fazer isso, e eu perdi algum tempo em avenidas que não deram certo. Geralmente, existem pelo menos essas opções:
Quando possível, considero a primeira dessas opções a mais limpa, mas não deu certo para Nier neste caso. O segundo induz alguma gestão feia do estado. O terceiro exige que você gerencie a vida útil do buffer, mas como estamos falando de apenas alguns carros alegóricos aqui, considerei essa a opção mais viável.
A segunda decisão a tomar é quando realmente executar os ajustes e como detectar que eles precisam ser executados. Aqui, geralmente vale a pena manter alguns fatores em mente:
Seguindo esses princípios, cheguei a este código:
void PreDraw (WrappedID3D11DeviceContext* context) {
UINT numViewports = 0 ;
context-> RSGetViewports (&numViewports, NULL );
if (numViewports == 1 ) {
D3D11_VIEWPORT vp;
context-> RSGetViewports (&numViewports, &vp);
if ((vp. Width == 800 && vp. Height == 450 )
|| (vp. Width == 400 && vp. Height == 225 )
|| (vp. Width == 200 && vp. Height == 112 )
|| (vp. Width == 100 && vp. Height == 56 )
|| (vp. Width == 50 && vp. Height == 28 )
|| (vp. Width == 25 && vp. Height == 14 )) {
ID3D11RenderTargetView *rtView = NULL ;
context-> OMGetRenderTargets ( 1 , &rtView, NULL );
if (rtView) {
D3D11_RENDER_TARGET_VIEW_DESC desc;
rtView-> GetDesc (&desc);
if (desc. Format == desc. Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc. Format == DXGI_FORMAT_R32_FLOAT) {
ID3D11Resource *rt = NULL ;
rtView-> GetResource (&rt);
if (rt) {
ID3D11Texture2D *rttex = NULL ;
rt-> QueryInterface <ID3D11Texture2D>(&rttex);
if (rttex) {
D3D11_TEXTURE2D_DESC texdesc;
rttex-> GetDesc (&texdesc);
if (texdesc. Width != vp. Width ) {
// Here we go!
// Viewport is the easy part
vp. Width = ( float )texdesc. Width ;
vp. Height = ( float )texdesc. Height ;
// if we are at mip slice N, divide by 2^N
if (desc. Texture2D . MipSlice > 0 ) {
vp. Width = ( float )(texdesc. Width >> desc. Texture2D . MipSlice );
vp. Height = ( float )(texdesc. Height >> desc. Texture2D . MipSlice );
}
context-> RSSetViewports ( 1 , &vp);
// The constant buffer is a bit more difficult
// We don't want to create a new buffer every frame,
// but we also can't use the game's because they are read-only
// this just-in-time initialized map is a rather ugly solution,
// but it works as long as the game only renders from 1 thread (which it does)
// NOTE: rather than storing them statically here (basically a global) the lifetime should probably be managed
D3D11_BUFFER_DESC buffdesc;
buffdesc. ByteWidth = 16 ;
buffdesc. Usage = D3D11_USAGE_IMMUTABLE;
buffdesc. BindFlags = D3D11_BIND_CONSTANT_BUFFER;
buffdesc. CPUAccessFlags = 0 ;
buffdesc. MiscFlags = 0 ;
buffdesc. StructureByteStride = 16 ;
D3D11_SUBRESOURCE_DATA initialdata;
ID3D11Buffer* replacementbuffer = NULL ;
ID3D11Device* dev = NULL ;
// If we are not rendering to a mip map for hierarchical Z, the format is
// [ 0.5f / W, 0.5f / H, W, H ] (half-pixel size and total dimensions)
if (desc. Texture2D . MipSlice == 0 ) {
static std::map<UINT, ID3D11Buffer*> buffers;
auto iter = buffers. find (texdesc. Width );
if (iter == buffers. cend ()) {
float constants[ 4 ] = { 0 . 5f / vp. Width , 0 . 5f / vp. Height , ( float )vp. Width , ( float )vp. Height };
initialdata. pSysMem = constants;
context-> GetDevice (&dev);
dev-> CreateBuffer (&buffdesc, &initialdata, &replacementbuffer);
buffers[texdesc. Width ] = replacementbuffer;
}
context-> PSSetConstantBuffers ( 12 , 1 , &buffers[texdesc. Width ]);
}
// For hierarchical Z mips, the format is
// [ W, H, LOD (Mip-1), 0.0f ]
else {
static std::map<UINT, ID3D11Buffer*> mipBuffers;
auto iter = mipBuffers. find (desc. Texture2D . MipSlice );
if (iter == mipBuffers. cend ()) {
float constants[ 4 ] = {vp. Width , vp. Height , ( float )desc. Texture2D . MipSlice - 1 , 0 . 0f };
initialdata. pSysMem = constants;
context-> GetDevice (&dev);
dev-> CreateBuffer (&buffdesc, &initialdata, &replacementbuffer);
mipBuffers[desc. Texture2D . MipSlice ] = replacementbuffer;
}
context-> PSSetConstantBuffers ( 8 , 1 , &mipBuffers[desc. Texture2D . MipSlice ]);
}
if (dev) dev-> Release ();
}
}
rt-> Release ();
}
}
rtView-> Release ();
}
}
}
}
void WrappedID3D11DeviceContext::Draw (UINT VertexCount, UINT StartVertexLocation)
{
if (VertexCount == 4 && StartVertexLocation == 0 ) PreDraw ( this );
// [...]
}Como você pode ver, é um pouco pesado garantir que estamos apenas tocando exatamente o que queremos tocar, mas é realmente mais demorado do que complicado. Basicamente, para cada traço de chamadas que se encaixem no padrão (4 vértices, 1 alvo, formatos específicos, tamanhos específicos), verificamos se nossa textura do RenderStarget tem um tamanho diferente da viewport atualmente definida e, se assim for, ajustamos a viewport e definimos um novo buffer constante no slot correspondente.
Com essas mudanças, agora começamos a trabalhar com oclusão ambiente de alta resolução: 
Ainda é um pouco artefato e pode ser ainda melhor, mas é uma grande melhoria.
No entanto, nosso quadro final - ou seja, a saída real do jogo - ainda está completamente quebrada. Para corrigir isso, precisamos voltar à nossa comparação de referência. Dessa forma, podemos determinar que há outro buffer de profundidade/estêncil de 800x450 do formato D24_UNORM_S8_UINT que precisa ser ajustado para corresponder aos nossos novos tamanhos de buffer AO, caso contrário, a API (com razão) se recusará a fazer qualquer renderização mais tarde no quadro.
O código completo com este problema resolvido pode ser encontrado no code.cpp . Observe que também inclui o Nier: uma correção de buffer de Bloom, que opera em um princípio semelhante.