Ce document est un tutoriel pour manipuler le rendu d'un jeu (généralement pour augmenter sa qualité) si vous n'avez qu'un binaire disponible. Si vous vous êtes déjà demandé comment quelque chose comme DSFIX, ou DPFIX, ou beaucoup de mes plugins Gedosato, c'est pour vous.
Si vous avez déjà pensé que ce serait génial si vous pouviez faire quelque chose comme ça aussi, alors encore mieux, c'est pour vous aussi. J'espère que cela vous fera gagner beaucoup de temps à trouver des choses qui deviennent une seconde nature lorsque vous faites cela depuis une demi-décennie.
Il y a 3 sections majeures dans ce document: " préambule " (ce que vous lisez actuellement), " analyse " et " manipulation ". "Analyse" traite de la détermination de ce que fait le jeu et de ce que nous devons probablement changer, tandis que "Manipulation" explique comment appliquer les changements réels.
Dans ce tutoriel, nous utiliserons ce logiciel:
En termes de connaissances fondamentales, pour profiter pleinement de ce tutoriel, il serait probablement bon d'avoir:
Il est probablement encore quelque peu utile sans certains d'entre eux.
Nous allons faire face à Nier: Automata , car c'est un excellent jeu et parce qu'il offre ce que je considérerais une quantité de défi "modérée" pour les types de tâches que nous souhaitons effectuer. Il joue également bien avec RenderDoc sans aucun amadou compliquée. Bien sûr, le tutoriel devrait être également applicable à un grand nombre d'autres jeux.
Aux fins d'un tutoriel (et pour tout type de travail et de modding vraiment), il est important d'avoir un objectif très clair. Notre objectif pour cet exercice est d' augmenter la résolution du rendu spatial de l'effet d'occlusion ambiant dans Nier: Automates. Je suis arrivé à cet objectif (comme la plupart des objectifs de modding) en jouant au jeu et en voyant quelque chose que je n'aimais pas, en termes de qualité d'image.
Pour comprendre comment le jeu effectue son rendu, nous l'exécuterons à partir de RenderDoc et capturerons un cadre . Cela nous permettra d'étudier tout ce qui se passe qui est lié au rendu de ce cadre particulier. Pour ce faire, nous devons pointer du rendu à l'exécutable du jeu et le lancer:
Ensuite, dans le jeu, nous passons à un endroit qui devrait être un bon endroit pour juger de l'effet que nous voulons manipuler et capturer un cadre en appuyant sur F12 (RenderDoc devrait montrer une superposition en jeu qui nous informe de ce raccourci). Après avoir quitté le jeu, RenderDoc se chargera automatiquement et nous montrera le Framedump en mode de relecture:
Je ne donnerai pas d'explication complète de tous les éléments d'interface utilisateur de RenderDoc. Ce qui est important pour nous maintenant, c'est que à gauche, le navigateur d'événements donne un calendrier chronologique de tous les événements de rendu, et que le spectateur de texture nous permet de voir la sortie de chaque appel de rendu. En ouvrant la visionneuse de texture et en naviguant vers le bas à travers les événements, nous devrions finalement atteindre quelque chose qui ressemble à un tampon d'occlusion ambiant brut. Il est montré dans la capture d'écran ci-dessus. Ce que nous voyons immédiatement là-bas est qu'il s'agit d'un tampon 800x450 , bien que notre résolution de rendu soit un 2560x1440 complet. C'est probablement le principal coupable pour la faible qualité que nous voyons, et c'est ce que nous devons changer.
Cependant, afin de comprendre tout ce que nous devons changer, nous devons également savoir quelles entrées que cette passe AO utilise. Heureusement, RenderDoc rend cela assez facile:
Nous sommes passés à l'onglet Entrées à droite et voyons maintenant que nous n'avons qu'une seule entrée. Tout comme notre sortie, il est de 800 par 450. Notez que, initialement, il serait simplement montré comme une zone noire plate. C'est souvent le cas avec des tampons de points flottants, si leurs valeurs ne tombent pas dans les plages, nous pouvons distinguer à l'œil nu. Comme vous pouvez le voir dans la partie en surbrillance en haut, nous pouvons manipuler le paramètre de plage dans RenderDoc pour rendre le contenu visible. Dans ce cas, il ressemble clairement à un tampon Z (stockant une valeur de profondeur pour chaque pixel), ce à quoi nous nous attendons comme entrée minimale pour un col SSAO.
Fait intéressant, nous voyons également (en bas) qu'il s'agit d'une texture avec 10 MIPMAP. Une enquête plus approfondie sur les passes de rendu l'utilisant comme source ou cible révèle que les cartes MIP individuelles sont peuplées juste avant le passage AO. Ici, cela aide beaucoup à avoir lu le papier obscurable ambiant évolutif, car il explique comment un Buffer zérarchique Z peut être utilisé pour accélérer et réduire considérablement les frais généraux de mémoire des calculs AO à grande échelle.
En regardant les appels de rendu juste après la passe AO initiale, nous voyons une passe de flou horizontale et verticale dépendante de la profondeur, qui est également typique de la plupart des implémentations SSAO.
Pour résumer, notre analyse initiale nous dit que:
R8G8B8A8_UNORM et stocke la couverture AO, l'autre est du format R32_FLOAT avec MIPMAP et stocke un tampon Z hiérarchique.Nous ferons référence à cette capture de cadre RenderDoc comme notre référence initiale , que nous pouvons utiliser pour rechercher ce qui devrait vraiment se produire au cas où quelque chose se passe mal (et il le fait toujours) une fois que nous commençons à manipuler les choses.
Afin de manipuler le rendu d'un jeu, nous devons faire fonctionner notre code dans son processus, et nous devons intercepter et potentiellement changer ses appels API 3D. Il s'agit d'un sujet énorme et intesté, et comme ce tutoriel se concentre sur la façon de comprendre et de modifier le processus de rendu plutôt que sur les détails techniques de l'injection de DLL et de l'accrochage, je n'irai pas plus loin dans ce domaine.
Ce que nous ferons pour nos expériences, c'est simplement utiliser RenderDoc comme véhicule d'injection. Cela a l'avantage d'être un outil vraiment solide et bien testé, ce qui nous permet de nous concentrer sur la détermination de ce que nous devons faire plutôt que pourquoi notre ensemble d'outils ne fonctionne pas pour un cas d'angle. Bien sûr, pour créer une version significativement distribuable et jouable, nous devons porter notre résultat final dans un cadre d'injection conçu à cette fin.
La première chose que nous devons faire pour augmenter la résolution de rendu est de rendre nos tampons suffisamment grands pour soutenir cette résolution. Pour ce faire, nous modifions la méthode WrappedID3D11Device::CreateTexture2D dans les sources 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 = ©
}
}
// [...]
} Le code est plutôt explicite, la pièce [...] fait référence à l'implémentation de rendu existante de la méthode.
Normalement, à ce stade, je examinerais également immédiatement d'autres choses pertinentes et connexes qui doivent être adaptées, telles que les fenêtres, les rectanges de ciseaux et les paramètres de shaders. Cependant, pour le tutoriel, il est instructif de voir ce qui se passe avec ce changement:

Comme vous pouvez le voir, ce qui se passe, c'est que notre rendu se casse complètement. Ce sera probablement toujours un résultat courant pendant que vous développez quelque chose comme ça. Alors, comment gérons-nous? C'est là que notre capture de trame de référence entre et s'avère très utile.
En prenant une autre capture et en la comparant côte à côte avec notre référence, nous pouvons avancer jusqu'à ce que nous voyions un problème. Habituellement, ce sera assez évident, et c'est donc dans ce cas: 
Comme vous pouvez le voir, seule une petite partie de notre tampon manipulé est utilisée par le GPU. Ce que vous ne pouvez pas voir dans la capture d'écran, mais ce qui est évident lors de l'inspection des appels de dessin ultérieurs dans le fichier de trace, c'est que cette erreur se propage à travers la carte MIP individuelle passe jusqu'à ce que rien ne reste.
Si vous êtes habitué à ces types de modding - ou après avoir lu ce tutoriel;) - Vous savez qu'il s'agit d'une manifestation très typique d'un paramètre de la fenêtre et / ou d'un paramètres de shaders qui ne sont pas ajustés correctement pour correspondre aux nouvelles tailles de tampon plus grandes.
Ce que nous devons enquêter, ce sont les paramètres de la fenêtre pour les appels de dessin pertinents, ainsi que s'il existe des paramètres de shader qui semblent dériver ou dépendre de la résolution du tampon. Nous trouvons ce qui suit pour la passe principale AO:
Nous voyons à la fois un réglage de la fenêtre à codé 800x450 codé ainsi qu'un tampon constant de shader pixel très suspect utilisé dans cet appel de dessin. Pour interpréter les deux premières valeurs, certains antécédents graphiques et / ou expérience sont à nouveau extrêmement utiles. Je soupçonnais immédiatement qu'ils étaient des tailles de pixels à la résolution du tampon d'entrée d'origine - celles-ci sont souvent fournies aux shaders afin qu'ils puissent accéder aux valeurs du quartier. Et en effet, 1.0/1600 = 0.000625 , et il en va de même pour la résolution verticale dans le deuxième composant.
Pour toutes les autres laissez-passer impliqués dans ce cas particulier, la situation est très similaire - la fenêtre et un paramètre de shader associé doivent être ajustés, et je n'entrerai pas dans les détails pour tous. Pour les passes Hiérarchiques Z, il est particulièrement important de s'assurer que le niveau de MIP correct utilisé comme un renderRarget est identifié, car il affecte le paramètre.
Ajuster en fait les fenêtres et en particulier les paramètres du shader est un peu délicat. Il y a plusieurs façons d'y aller, et j'ai perdu du temps dans des avenues qui ne se sont pas déroulées. Généralement, il y a au moins ces options:
Lorsque cela est possible, je considère que la première de ces options est la plus propre, mais elle n'a pas fonctionné pour Nier dans ce cas. Le second induit une gestion de l'État laide. Le troisième vous oblige à gérer les durées de vie du tampon, mais comme nous parlons de quelques flotteurs ici, j'ai considéré cela comme l'option la plus viable.
La deuxième décision à prendre est de savoir quand effectuer réellement les ajustements et comment détecter qu'ils doivent être effectués. Ici, il est généralement payant pour garder à l'esprit quelques facteurs:
Suivant ces principes, je suis arrivé à ce code:
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 );
// [...]
}Comme vous pouvez le voir, il est un peu lourd de s'assurer que nous ne touchons que ce que nous voulons toucher, mais c'est vraiment plus long que compliqué. Fondamentalement, pour chaque appel de dessin ajusté le motif (4 sommets, 1 cible, formats spécifiques, tailles spécifiques), nous vérifions si notre texture Rendertarget a une taille différente de la fenêtre actuellement définie, et si c'est le cas, nous ajustez la fenêtre et définissons un nouveau tampon constant dans la fente correspondante.
Avec ces changements, nous travaillons maintenant sur l'occlusion ambiante à haute résolution: 
C'est encore un peu artefacté et pourrait être encore meilleur, mais c'est une amélioration assez énorme.
Cependant, notre cadre final - c'est-à-dire la sortie réelle du jeu - est toujours complètement rompu. Pour résoudre ce problème, nous devons reprendre notre comparaison de référence. De cette façon, nous pouvons déterminer qu'il existe un autre tampon de profondeur / pochoir 800x450 du format D24_UNORM_S8_UINT qui doit être ajusté pour correspondre à nos nouvelles tailles de tampon AO, sinon l'API refusera (à juste titre) de faire un rendu plus tard dans le cadre.
Le code complet avec ce problème résolu peut être trouvé dans code.cpp . Notez qu'il comprend également le Nier: une correction de tampon de floraison, qui fonctionne sur un principe similaire.