Este documento es un tutorial para manipular la representación de un juego (generalmente para aumentar su calidad) si solo tiene un binario disponible. Si alguna vez se preguntó cómo funcionan algo como DSFIX, o DPFIX, o muchos de mis complementos Gedosato, entonces esto es para usted.
Si alguna vez has pensado que sería genial si pudieras hacer algo así también, entonces aún mejor, esto también es para ti. Con suerte, le ahorrará mucho tiempo descubrir cosas que se convierten en una segunda naturaleza cuando lo has estado haciendo durante media década más o menos.
Hay 3 secciones principales en este documento: " Preámbulo " (lo que está leyendo en este momento), " Análisis " y " Manipulación ". "Análisis" se ocupa de descubrir qué hace el juego y lo que probablemente necesitemos cambiar, mientras que "Manipulación" explica cómo aplicar los cambios reales.
En este tutorial, utilizaremos este software:
En términos de conocimiento fundamental, para obtener el beneficio completo de este tutorial, probablemente sería bueno tener:
Probablemente todavía sea algo útil sin algunos de esos.
Trataremos con Nier: Automata , porque es un gran juego y porque ofrece lo que consideraría una cantidad "moderada" de desafío para los tipos de tareas que deseamos realizar. También juega bien con RenderDoc sin ningún coaxo complicado. Por supuesto, el tutorial debe ser igualmente aplicable a muchos otros juegos.
A los efectos de un tutorial (y para cualquier tipo de trabajo y modificación realmente) es importante tener un objetivo muy claro. Nuestro objetivo para este ejercicio es aumentar la resolución de renderizado espacial del efecto de oclusión ambiental en Nier: Automata. Llegué a este objetivo (como la mayoría de mis goles de modificación) jugando el juego y viendo algo que no me gustó, en cuanto a la calidad de la imagen.
Para comprender cómo el juego realiza su representación, lo ejecutaremos desde RenderDoc y capturaremos un marco . Esto nos permitirá investigar todo lo que sucede, lo que está relacionado con la representación de ese marco en particular. Para hacerlo, necesitamos señalar RenderDoc en el ejecutable del juego y lanzarlo:
Luego, en el juego, nos movemos a un lugar que debería ser un buen lugar para juzgar el efecto que queremos manipular y capturar un marco presionando F12 (RenderDoc debe mostrar una superposición en el juego que nos informa de este atajo). Después de salir del juego, RenderDoc se cargará automáticamente y nos mostrará el marco en modo de repetición:
No daré una explicación completa de todos los elementos de la interfaz de usuario de RenderDoc. Lo importante para nosotros ahora es que a la izquierda, el navegador de eventos ofrece una línea de tiempo cronológica de todos los eventos de representación, y que el visor de textura nos permite ver la salida de cada llamada de representación. Al abrir el espectador de textura y navegar hacia abajo a través de los eventos, eventualmente debemos alcanzar algo que parece un búfer de oclusión ambiental cruda. Se muestra en la captura de pantalla de arriba. Lo que vemos inmediatamente es que es un búfer de 800x450 , a pesar de que nuestra resolución de renderizado es un 2560x1440 completo. Este es probablemente el principal culpable de la baja calidad que estamos viendo, y es lo que necesitamos cambiar.
Sin embargo, para comprender todo lo que necesitamos cambiar, también necesitamos saber qué entradas usa este pase AO. Afortunadamente, RenderDoc lo hace bastante fácil:
Hemos cambiado a la pestaña Entradas a la derecha, y ahora vemos que solo tenemos una sola entrada. Al igual que nuestra salida, es 800 por 450. Tenga en cuenta que inicialmente se mostraría como un área negra plana. Este es a menudo el caso con los buffers de puntos flotantes, si sus valores no caen en los rangos, podemos distinguir a simple vista. Como puede ver en la parte resaltada en la parte superior, podemos manipular la configuración de rango en RenderDoc para hacer que el contenido sea visible. En este caso, claramente parece un búfer Z (almacenando un valor de profundidad para cada píxel), que es lo que esperaríamos como la entrada mínima a un pase SSAO.
Curiosamente, también vemos (en la parte inferior) que es una textura con 10 mipmaps. Una investigación adicional de los pases de representación lo usa como fuente u objetivo revela que los mapas MIP individuales están poblados justo antes del PASE AO. Aquí ayuda mucho haber leído el papel de Obscurencia Ambiental escalable, ya que explica cómo un buffer z jerárquico puede usarse para acelerar en gran medida y reducir la sobrecarga de memoria de los cálculos AO a gran escala.
Mirando las llamadas de renderizado justo después del pase AO inicial, vemos un pase de desenfoque horizontal y vertical dependiente de la profundidad, que también es típico de la mayoría de las implementaciones de SSAO.
Para resumir, nuestro análisis inicial nos dice que:
R8G8B8A8_UNORM y almacena la cobertura AO, la otra es del formato R32_FLOAT con MIPMAPS y almacena un búfer z jerárquico.Nos referiremos a esta captura de marco RenderDoc como nuestra referencia inicial , que podemos usar para buscar lo que realmente debería estar sucediendo en caso de que algo salga mal (y siempre lo hace) una vez que comenzamos a manipular las cosas.
Para manipular la representación de un juego, necesitamos que nuestro código se ejecute en su proceso, y necesitamos interceptar y potencialmente cambiar sus llamadas de API 3D. Este es un tema enorme e laboral intensivo, y dado que este tutorial se centra en cómo comprender y cambiar el proceso de representación en lugar de los tecnicismos de la inyección y el enganche de DLL, no iré más lejos en esto.
Lo que haremos para nuestros experimentos es simplemente usar RenderDoc como nuestro vehículo de inyección. Esto tiene la ventaja de ser una herramienta realmente sólida y bien probada, que nos permite centrarnos en descubrir qué debemos hacer en lugar de por qué nuestro conjunto de herramientas no funciona para algún caso de esquina. Por supuesto, para crear una versión significativa distribuible y jugable, necesitamos transferir nuestro resultado final en algún marco de inyección diseñado para ese propósito.
Lo primero que debemos hacer para aumentar la resolución de renderizado es hacer que nuestros amortiguadores sean lo suficientemente grandes como para respaldar esa resolución. Para hacerlo, cambiamos el método WrappedID3D11Device::CreateTexture2D en las fuentes de 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 = ©
}
}
// [...]
} El código se explica por sí mismo, la parte [...] se refiere a la implementación de RenderDoc existente del método.
Normalmente, en este punto, también analizaría inmediatamente otras cosas relevantes y relacionadas que deben adaptarse, como vistas, rectangos de tijera y parámetros del sombreador. Sin embargo, para el tutorial, es instructivo ver qué sucede con este cambio:

Como puede ver, lo que sucede es que nuestra representación se rompe por completo. Esto probablemente siempre será un resultado común mientras desarrolla algo como esto. Entonces, ¿cómo lo tratamos? Aquí es donde entra nuestra captura de marco de referencia y resulta muy útil.
Tomando otra captura y comparándola lado a lado con nuestra referencia, podemos avanzar hasta que veamos un problema. Por lo general, será bastante obvio, por lo que es en este caso: 
Como puede ver, la GPU está utilizando solo una pequeña parte de nuestro búfer manipulado. Lo que no puede ver en la captura de pantalla, pero lo que es obvio al inspeccionar las llamadas de sorteo posteriores en el archivo de rastreo es que este error se propaga a través del mapa MIP individual se pasa hasta que básicamente no queda nada.
Si está acostumbrado a este tipo de modificación, o después de leer este tutorial;), sabe que esta es una manifestación muy típica de una configuración de ventana gráfica y/o parámetros de sombreador que no se ajustan correctamente para que coincidan con los nuevos tamaños de búfer.
Lo que necesitamos investigar son la configuración de la ventana gráfica para las llamadas de dibujo relevantes, así como si hay algún parámetros de sombreador que parezcan derivarse o depender de la resolución del amortiguador. Encontramos lo siguiente para el Pase AO principal:
Vemos tanto una configuración de ventana gráfica 800x450 codificada como un búfer constante de sombreador de píxeles muy sospechoso utilizado en esta llamada de sorteo. Para interpretar los dos primeros valores, algunos antecedentes y/o experiencia gráficos son nuevamente extremadamente útiles. Inmediatamente sospeché que eran tamaños de píxeles en la resolución de búfer de entrada original; a menudo se suministran a los sombreadores para que puedan acceder a los valores del vecindario. Y de hecho, 1.0/1600 = 0.000625 , y lo mismo es cierto para la resolución vertical en el segundo componente.
Para todos los demás pases involucrados en este caso particular, la situación es muy similar: tanto la ventana contra la ventana contra la ventana gráfica como el de sombreador se deben ajustar, y no voy a entrar en detalles para todos ellos. Para los pases Z jerárquicos, es particularmente importante asegurarse de que se identifique el nivel de MIP correcto como RenderTarget, ya que afecta la configuración de los parámetros.
En realidad, ajustar las vistas y, en particular, los parámetros del sombreador es un poco complicado. Hay múltiples formas de hacerlo, y perdí algún tiempo en vías que no funcionaban. En general, hay al menos estas opciones:
Cuando sea posible, considero que la primera de estas opciones es la más limpia, pero no funcionó para Nier en este caso. El segundo induce una gestión estatal fea. El tercero requiere que maneje la vida útil del búfer, pero dado que estamos hablando de unos pocos flotadores aquí, consideré que esa era la opción más viable.
La segunda decisión que debe tomar es cuándo realizar los ajustes y cómo detectar que deben realizarse. Aquí, generalmente vale la pena tener en cuenta algunos factores:
Siguiendo estos principios, llegué 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 puede ver, es un poco engorroso asegurarse de que solo estamos tocando exactamente lo que queremos tocar, pero es realmente más largo que complicado. Básicamente, para cada dibujo de llamadas que se ajuste al patrón (4 vértices, 1 objetivo, formatos específicos, tamaños específicos), verificamos si nuestra textura de RenderTarget tiene un tamaño diferente al de la vista actualmente establecida, y si es así, ajustamos la vista y establecemos un nuevo búfer constante en la ranura correspondiente.
Con estos cambios, ahora trabajamos en la oclusión ambiental de mayor resolución: 
Todavía está un poco arte de arte, y podría ser aún mejor, pero es una mejora bastante grande.
Sin embargo, nuestro marco final, es decir, la salida real del juego, todavía está completamente rota. Para solucionar esto, necesitamos volver a nuestra comparación de referencia. De esa manera, podemos determinar que hay otro búfer de profundidad/plantilla 800x450 de formato D24_UNORM_S8_UINT que debe ajustarse para que coincida con nuestros nuevos tamaños de búfer AO, de lo contrario, la API (con razón) se negará a hacer cualquier representación más adelante en el marco.
El código completo con este problema resuelto se puede encontrar en code.cpp . Tenga en cuenta que también incluye el Nier: una solución de búfer Bloom, que funciona con un principio similar.