本文档是一个教程,用于操纵游戏的渲染(通常是为了提高质量),如果您只有二进制文件。如果您想知道诸如DSFIX,DPFIX或我的许多Gedosato插件之类的东西如何工作,那么这是给您的。
如果您曾经以为如果您也可以做类似的事情,那就太好了,那就更好了,这也适合您。希望您能为您节省大量时间来弄清楚当您做这件事五年左右时成为第二天性的事情。
本文档中有3个主要部分:“序言”(您现在正在阅读的内容),“分析”和“操纵”。 “分析”涉及弄清游戏的作用以及我们可能需要更改的内容,而“操纵”解释了如何应用实际更改。
在本教程中,我们将使用此软件:
就基本知识而言,为了获得本教程的全部利益,拥有:
没有其中一些,它可能仍然有用。
我们将与Nier:Automata打交道,因为这是一款出色的游戏,并且因为它为我们希望执行的任务类型提供了“适度”的挑战。它也可以与Renderdoc一起发挥不错的作用,而没有任何复杂的哄骗。当然,本教程应同样适用于许多其他游戏。
出于教程的目的(实际上,对于任何类型的工作和改装),实现非常明确的目标很重要。我们的这项练习的目标是增加NIER:自动机中环境闭塞效应的空间渲染分解。我通过玩游戏并看到我不喜欢的东西来达到这个目标(就像大多数改装目标一样)。
为了了解游戏的执行方式,我们将从renderdoc内部运行它并捕获框架。这将使我们能够调查与渲染该特定框架有关的一切。为此,我们需要将RenderDoc指向游戏可执行文件并启动它:
然后,在游戏中,我们移至一个位置,应该是判断我们要操纵的效果的好地方,并通过按F12来捕获框架(RenderDoc应该显示一个游戏中的覆盖层,以告知我们此快捷方式)。退出游戏后,RenderDoc将自动加载并在重播模式下向我们展示FramedUmp:
我不会对RenderDoc的所有UI元素做出完整的解释。现在对我们来说重要的是,在左侧,事件浏览器给出了所有渲染事件的时间表,并且纹理查看器允许我们看到每个渲染调用的输出。通过打开纹理查看器并通过事件向下导航,我们最终应该达到看起来像原始环境遮挡缓冲液的东西。它显示在上面的屏幕截图中。我们立即看到的是它是800x450缓冲区,尽管我们的渲染分辨率是完整的2560x1440。这可能是我们所看到的低品质的主要罪魁祸首,这是我们需要改变的罪魁祸首。
但是,为了了解我们需要更改的所有内容,我们还需要知道该AO Pass使用的哪些输入。幸运的是,Renderdoc使它变得很容易:
我们已经切换到右侧的“输入”选项卡,现在看到我们只有一个输入。就像我们的输出一样,它是800 x 450。请注意,最初它将仅显示为平坦的黑色区域。浮点缓冲区通常是这种情况,如果它们的值不属于范围,我们可以用肉眼区分。正如您在顶部的突出显示部分中看到的那样,我们可以操纵RenderDoc中的范围设置以使内容可见。在这种情况下,显然看起来像Z缓冲区(为每个像素存储一个深度值),这是我们期望的作为SSAO Pass的最小输入。
有趣的是,我们还看到(底部)它是带有10个MIPMAP的纹理。用它作为源或目标,对渲染通行证的进一步研究表明,在AO通行证之前,单个MIP映射是填充的。在这里,它有助于阅读可扩展的环境晦涩的纸张,因为它解释了如何使用层次结构Z-buffer大大加速和减少大规模AO计算的内存开销。
查看初始AO通行证后的渲染调用,我们看到了水平和垂直深度依赖性模糊通行证,这也是大多数SSAO实现的典型代表。
总而言之,我们的初步分析告诉我们:
R8G8B8A8_UNORM格式的,并存储AO覆盖范围,另一个是带有MIPMAP的R32_FLOAT格式,并存储了层次结构Z缓冲区。我们将把这个renderdoc框架捕获为我们的初始参考,我们可以用它来查找真正发生的事情,以防万一出现问题(并且总是会做),一旦我们开始操纵事物。
为了操纵游戏的渲染,我们需要在其过程中运行代码,我们需要拦截并可能更改其3D API调用。这是一个巨大而劳动力的主题,由于本教程着重于如何理解和更改渲染过程,而不是DLL注入和钩的技术,所以我不会进一步研究。
我们将为实验所做的只是将RenderDoc用作注射工具。这具有成为一个非常扎实且经过充分测试的工具的优点,这使我们能够专注于弄清楚我们需要做什么,而不是为什么我们的工具集对某些角案件不起作用。当然,要实际创建一个有意义的分发和可玩版本,我们需要将最终结果移植到为此目的设计的一些注射框架中。
为了增加渲染分辨率,我们需要做的第一件事是使我们的缓冲区足够大以支持该分辨率。为此,我们更改了RenderDoc来源中的WrappedID3D11Device::CreateTexture2D方法:
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 = ©
}
}
// [...]
}该代码相当不言自明, [...]部分是指该方法的现有RenderDoc实现。
通常,在这一点上,我还将立即研究需要改编的其他相关和相关的事物,例如视口,剪刀矩形和着色器参数。但是,对于本教程,看到此更改会发生什么是有启发性的:

如您所见,发生的事情是我们的渲染完全破裂。当您开发这样的东西时,这可能永远是一个普遍的结果。那么我们如何处理呢?这是我们的参考框架捕获来的地方,并证明非常有用。
采取另一个捕获并将其并排比较我们的参考,我们可以向前迈进,直到看到出现问题为止。通常,这将是相当明显的,因此在这种情况下: 
如您所见,GPU仅使用了我们操纵的缓冲区的一小部分。您在屏幕截图中看不到的是,在跟踪文件中检查后续绘制调用时,显而易见的是,此错误在单个MIP地图上传播到传播,直到基本上没有剩下。
如果您已经习惯了这些修改,或者在阅读本教程之后;) - 您知道这是视口设置和/或阴暗器参数未正确调整以匹配新的,较大的缓冲尺寸的非常典型的表现。
我们需要调查的是相关抽奖呼叫的视口设置,以及是否有任何阴暗参数似乎来自或取决于缓冲区分辨率。我们为主要AO通行证找到以下内容:
我们看到了此抽奖调用中使用的非常可疑的像素着色器恒定缓冲区,也看到了一个硬编码的800x450视口设置。为了解释前两个值,一些图形背景和/或经验再次非常有帮助。我立即怀疑它们是原始输入缓冲区分辨率的像素尺寸 - 通常将其提供给着色器,以便它们可以访问邻里值。实际上, 1.0/1600 = 0.000625 ,第二个组件中的垂直分辨率也是如此。
对于这种特定情况下涉及的所有其他通过,情况非常相似 - 需要调整视口和相关的着色器参数,并且我不会详细介绍所有这些参数。对于层次Z通过,确保将正确的MIP级别用作renderTarget的正确级别尤其重要,因为它会影响参数设置。
实际调整视口,尤其是着色器参数有点棘手。有多种方法可以解决,我浪费了一些时间,这些途径没有淘汰。通常,至少有这些选择:
如果可能的话,我认为这些选项中的第一个是最干净的,但是在这种情况下,它对NIER没有效果。第二个引起了一些丑陋的国家管理。第三个要求您管理缓冲寿命,但是由于我们在这里只谈论几个浮标,所以我认为这是最可行的选择。
第二个决定是何时实际执行调整,以及如何检测需要执行它们。在这里,通常会牢记一些因素:
遵循这些原则,我得出了此守则:
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 );
// [...]
}如您所见,确保我们只触摸我们想要触摸的东西有点麻烦,但是它比复杂的要冗长。基本上,对于拟合图案的每个绘制呼叫(4个顶点,1个目标,特定格式,特定尺寸),我们检查我们的rendertarget纹理是否具有与当前设置的视口不同的大小,如果这样,我们调整了视口并在相应插槽中设置新的常数缓冲区。
通过这些更改,我们现在开始使用更高分辨率的环境遮挡: 
它仍然有些伪装,甚至可以更好,但这是一个相当大的进步。
但是,我们的最后帧(即实际的游戏输出)仍然完全破坏。为了解决这个问题,我们需要回到参考比较。这样,我们可以确定格式D24_UNORM_S8_UINT的另一个800x450深度/模板缓冲区需要进行调整以匹配我们的新AO缓冲区大小,否则API(正确地)将拒绝以后在框架后来进行任何渲染。
解决此问题的完整代码可以在code.cpp中找到。请注意,它还包括NIER:Bloom Buffer Fix,以类似的原理运行。