前言
RenderDoc等一系列抓帧工具的原理,是在运行前,在图形API初始化之前将自己的dll注入到目标程序中,并hook一系列图形API,得到API调用时的参数。如果在图形API初始化之后Hook,很可能出现无法检测到图形API(打开UI后,上面的API检测显示none)。
我们自己写的图形程序如果没加保护,能直接用RenderDoc注入进去。
注入方式
不管是注入自己写的dll,还是renderdoc等已经写好的dll,都需要一个注入器。现有的注入器,例如RemoteDLL就很不错,简单轻便:
打开程序后,选择目标进程,然后选择DLL,点击注入。
注意:注入目标程序、注入器、DLL,三者的32位、64位必须一致,并且如果目标程序有管理员权限,注入器没有,就可能导致注入器找不到目标程序。
这只是基本需求,但图形API的特殊性,很多函数需要在图形API初始化之前Hook,这就需要在程序打开的第一时间注入dll,因此我们需要自己实现一个注入器。
实现基本注入器需要引入的头文件:
#include <windows.h>
#include <iostream>
#include <tchar.h>
首先我们设定要打开程序的exe路径、命令行参数、以及工作目录,后两者并不是必须的。命令行参数自然不必多说,工作目录一般是我们自己写Visual Studio时,编译链接出来的exe才会和工作路径不一致,后两者如果没有特殊需求,都可以是NULL。
TCHAR szExePath[] = TEXT("你的exe路径");//你的EXE路径
TCHAR szForceDX12Cmdline[] = TEXT("-force-d3d12");//你的命令行参数,这个事例参数是强制unity游戏以d3d12运行,可以根据需要更改
TCHAR szWorkspace[] = TEXT("程序的工作目录");//程序的工作目录
然后利用CreateProcess创建进程,打开程序:
//CreateProcess的返回值
BOOL bSuccess = FALSE;
//CreateProcess传出的进程信息
PROCESS_INFORMATION pi;
STARTUPINFO si;
ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
ZeroMemory(&si, sizeof(STARTUPINFO));
si.cb = sizeof(STARTUPINFO);
si.dwFlags |= STARTF_USESTDHANDLES;
bSuccess = CreateProcess(
szExePath,//exe路径
szForceDX12Cmdline,//命令行参数
NULL,
NULL,
TRUE,
0,
NULL,
szWorkspace,//工作路径
&si,
&pi
);
if (!bSuccess)
{
std::cout << "创建失败" << std::endl;
}
else
{
std::cout << "成功,进程号为:" << pi.dwProcessId << std::endl;
}
这样就能打开进程(如果成功),并通过PROCESS_INFORMATION对象得到进程信息。
然后我们写一个注入方法,参数是dll的地址和目标进程号:BOOL Inject(LPCTSTR DLLPath, DWORD ProcessID)
。
我们选择远程线程注入方法。每个进程之间的空间彼此隔离,注入器无法操控目标进程的空间,但可以通过开启一个远程线程的方法。
我对远程线程注入并不能描述的很明白,但网上资料有很多,这里放上我的代码:
BOOL Inject(LPCTSTR DLLPath, DWORD ProcessID)
{
HANDLE hProcess = nullptr;
hProcess = OpenProcess(PROCESS_ALL_ACCESS, TRUE, ProcessID);
if (!hProcess)
{
std::cout << "打开目标进程句柄失败" << std::endl;
return FALSE;
}
SIZE_T PathSize = (_tcslen(DLLPath) + 1) * sizeof(TCHAR);
LPVOID StartAddress = VirtualAllocEx(hProcess, NULL, PathSize, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (!StartAddress)
{
std::cout << "申请路径地址空间失败" << GetLastError() << std::endl;
return FALSE;
}
if (!WriteProcessMemory(hProcess, StartAddress, DLLPath, PathSize, NULL))
{
std::cout << "传入路径地址空间失败" << std::endl;
return FALSE;
}
PTHREAD_START_ROUTINE pfnStartAddress = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(_T("kernel32.dll")), "LoadLibraryW");
if (!pfnStartAddress)
{
std::cout << "获取LoadLibraryW函数地址失败" << std::endl;
return FALSE;
}
HANDLE hThread = CreateRemoteThreadEx(hProcess, NULL, NULL, pfnStartAddress, StartAddress, NULL, NULL, NULL);
if (!hThread)
{
std::cout << "打开远程线程失败" << std::endl;
return FALSE;
}
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
}
大概是找到kernel32.dll的LoadLibraryW方法的地址,并调用加载DLL。
最后注入就完事了:
TCHAR RenderDocDll[] = TEXT("我的renderdoc.dll的地址");
if (!Inject(RenderDocDll, pi.dwProcessId))
std::cout << "创建远程线程失败" << std::endl;
else
std::cout << "成功创建远程线程" << std::endl;
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
这样就能通过远程注入的方式,将renderdoc注入图形API程序中,分析渲染过程。
但假如有人不止想做这么多呢?
在在SwapChain执行Present前,对RenderTarget进行一次后处理,岂不是能做出类似调色个功能?
如果把人物渲染DrawCall的深度测试关闭,岂不是就能透视?
很多外挂或图形调整插件都是这么做的。我们也可以自己实现一个dll,用来hook图形API。
inline hook DLL
首先要引入一票头文件:
#include <Windows.h>
//提供_beginthreadex函数
#include <process.h>
//用于拍摄快照,检查当前进程已经加载了哪些dll
#include <TlHelp32.h>
#include <tchar.h>
//d3d的头文件
#include <d3d11.h>
#include <dxgi.h>
#include <d3dx11tex.h>
//STL
#include <string>
#include <sstream>
#include <vector>
#include <iostream>
d3dx11tex.h是我用来保存RT的,需要在微软下载DirectX2010 SDK安装,这里面d3d相关的库都需要链接:
#pragma comment(lib, "d3d11.lib")
#pragma comment(lib, "dxgi.lib")
#pragma comment(lib, "d3dx11.lib")
如果找不到链接lib文件,还要到项目属性>链接器>常规>附加库目录中,把lib目录填入。(不知道在哪就用everything找,找不到就上网查一查安装)。
除此外我也不打算手动实现inline hook(因为菜),所以hook交给微软的hook库Detours就好了。下载源码后,打开VS (2017)的开发人员命令提示符(可以在VS工具菜单/命令行/开发者命令提示中找到),进入src目录下,输入nmake命令,在得到的include目录下得到detours.h头文件,并链接lib.X64目录下detours.lib。
dll的main函数:
BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD fdwReason, LPVOID)
{
DisableThreadLibraryCalls(hInstance);
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
_beginthreadex(nullptr, 0, init, nullptr, 0, nullptr);
break;
}
return TRUE;
}
DLL_PROCESS_ATTACH是当dll注入进程后调用。
init函数用于初始化,我们还未实现,现在就来实现init函数。
unsigned int __stdcall init(void* data)
{
return 0;
}
嗯,这就是基础的init函数,假如在里面写一个MessageBox,用注入器注入后,就可以弹出一个对话框。为了方便我们调试,可以在目标进程中打开一个对话框:
bool OpenConsole()
{
if (AllocConsole()) {
freopen("CONOUT$", "w", stdout);
SetConsoleTitle(L"Debug Console");
SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), FOREGROUND_GREEN | FOREGROUND_BLUE | FOREGROUND_RED);
std::cout << "Hello Inject!" << std::endl;
return true;
}
return false;
}
接下来要Hook D3D11的方法,有两种,一种是类似D3D11CreateDevice这样,作用域是全局的函数,另一种是类似IDXGISwapChain::Present这样的成员函数。先说后者。
VMT Hook
我不清楚怎么找一个成员函数的地址,或许直接用类名ClassName::*MethodName,不过D3D这些成员函数都有些特殊,它们都是虚函数,地址存储在虚表中。
根据C++对象内存布局,如果对象有虚函数,那么对象最前面就是虚表指针vfptr,我们可以用x64dbg,或VS的命令行查看IDXGISwapChain的内存布局:解决方案属性>C++>命令行>添加 /d1 reportSingleClassLayoutIDXGISwapChain >应用>编译项目,就能看到SwapChain的布局:
IDXGISwapChain : public IDXGIDeviceSubObject
IDXGIDeviceSubObject : public IDXGIObject
IDXGIObject : public IUnknown
IUnknown
为了方便查表,我们可以把生成的表单粘贴下来做成枚举:
//D3D_VMT_Indices.h
//VMT是Virtual Method Table的缩写
enum class IDXGISwapChainVMT{
QueryInterface,
AddRef,
Release,
SetPrivateData,
SetPrivateDataInterface,
GetPrivateData,
GetParent,
GetDevice,
Present,
GetBuffer,
SetFullscreenState,
GetFullscreenState,
GetDesc,
ResizeBuffers,
ResizeTarget,
GetContainingOutput,
GetFrameStatistics,
GetLastPresentCount
};
在DX11中,除了SwapChain外,最常用的还有ID3D11Device和ID3D11DeviceContext的虚表方法,用同样的方法写出这两个类的虚表枚举。
要Hook这些虚函数,先要获取它们的虚表指针,我们获取不到目标程序创建的Device、Context、SwapChain对象,但好笑的是,相同类型的对象共用一个虚表指针的地址,所以我们可以创建一个Device、Context、SwapChain,虽然这些不能用于渲染,但可以得到虚表指针,然后Hook其中的虚函数,当D3D程序内部Device等对象调用这些函数时,会自己把自己送给我们。
当然,当前的任务还是获取虚表指针,为此我们创建这三个对象:
void** g_pDeviceVMT = nullptr;
void** g_pSwapchainVMT = nullptr;
void** g_pDeviceContextVMT = nullptr;
//用于创建Device、SwapChain、Context,只要能成功创建出来,参数是随意的
bool GetD3D11VMT()
{
//这些对象只是为了获取虚表,并不需要被使用
ID3D11Device* l_pDevice = nullptr;
IDXGISwapChain* l_pSwapchain = nullptr;
ID3D11DeviceContext* l_pDeviceContext = nullptr;
DXGI_SWAP_CHAIN_DESC scd;
ZeroMemory(&scd, sizeof(DXGI_SWAP_CHAIN_DESC));
scd.BufferCount = 1;
scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
scd.BufferDesc.Width = 1920;
scd.BufferDesc.Height = 1080;
scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
scd.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
scd.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
scd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
scd.OutputWindow = GetForegroundWindow();
scd.BufferDesc.RefreshRate.Numerator = 60;
scd.BufferDesc.RefreshRate.Denominator = 1;
scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
scd.SampleDesc.Count = 1;
scd.SampleDesc.Quality = 0;
scd.Windowed = ((GetWindowLongPtr(GetForegroundWindow(), GWL_STYLE) & WS_POPUP) != 0) ? false : true;
D3D_FEATURE_LEVEL featLevel;
HRESULT hr = D3D11CreateDeviceAndSwapChain(
nullptr,
D3D_DRIVER_TYPE_REFERENCE,
nullptr,
0,
nullptr,
0,
D3D11_SDK_VERSION,
&scd,
&l_pSwapchain,
&l_pDevice,
&featLevel,
nullptr
);
if (FAILED(hr))
{
std::cout << "创建D3D11Device和SwapChain失败" << std::endl;
return false;
}
l_pDevice->GetImmediateContext(&l_pDeviceContext);
//获取虚表
g_pSwapchainVMT = *(void***)l_pSwapchain;
g_pDeviceVMT = *(void***)l_pDevice;
g_pDeviceContextVMT = *(void***)l_pDeviceContext;
std::cout << "获取虚表成功" << std::endl;
return true;
}
此时我们就可以通过Detours Hook虚函数了,我这里演示下Present的Hook流程:
//定义Present的类型,注意因为是成员虚函数,第一个参数要传入对象的地址
using vfn_SwapChain_Present = HRESULT(WINAPI*) (IDXGISwapChain* pThis, UINT SyncInterval, UINT Flags);
//原来Present的地址
vfn_SwapChain_Present oPresent = nullptr;
//替换Present的方法
HRESULT WINAPI HookFuncSwapChainPresent(IDXGISwapChain* pThis, UINT SyncInterval, UINT Flags)
{
//输出一句话,并调用原来的Present方法
std::cout << "Hook Present" << std::endl;
return oPresent(pThis, SyncInterval, Flags);
}
bool HookPresent()
{
void** p_SwapChain_VMT = g_pSwapchainVMT;
oPresent = (vfn_SwapChain_Present)(p_SwapChain_VMT[(UINT)IDXGISwapChainVMT::Present]);
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
//主要是这一句,将原来的Present替换成我们的Present
DetourAttach((PVOID*)&oPresent, HookFuncSwapChainPresent);
DetourTransactionCommit();
return true;
}
这样就能做不少事,例如我们可以Hook IDXGISwapChain::Present和ID3D11DeviceContext::DrawIndexed \ DrawIndexedInstanced,从而统计每渲染一帧的DrawCall数量。
我们也可以试着像RenderDoc那样,将每一个DrawCall后的RT保存下来。
我们可以Hook ID3D11CreateDeviceAndSwapChain直接获取Device和SwapChain并全局保存,但这个方法一会再提,我们先偷个懒,在Present第一次运行的时候,通过传递过来的SwapChain的GetDevice方法获取Device,然后通过Device的GetImmediateContext方法获取Context:
//全局变量
IDXGISwapChain* g_pSwapchain = nullptr;
ID3D11Device* g_pDevice = nullptr;
ID3D11DeviceContext* g_pContext = nullptr;
bool IsInit()
{
return (g_pDevice != nullptr) && (g_pSwapchain != nullptr) && (g_pContext != nullptr);
}
void InitD3D(IDXGISwapChain* pSwapChain)
{
if (!IsInit())
{
g_pSwapchain = pSwapChain;
pSwapChain->GetDevice(__uuidof(ID3D11Device), (void**)&g_pDevice);
g_pDevice->GetImmediateContext(&g_pContext);
}
}
//上面声明的 HookFuncSwapChainPresent 方法内加上
if (!IsInit())
{
InitD3D(pThis);
}
然后写一些截屏的逻辑
//全局变量
bool doCapture = false;
bool Capturing = false;
int gCaptureNum = 0;
void TriggerCapture()
{
doCapture = true;
}
//上面声明的 HookFuncSwapChainPresent 中加入:
if (Capturing)//如果上一帧在截屏,关闭截屏
Capturing = false;
if (doCapture)//如果要截屏
{
doCapture = false;
Capturing = true;
gCaptureNum = 0;
std::cout << "截帧" << std::endl;
}
要保存RT,就要获取当前RT,我们可以通过Hook Context的OMSetRenderTargets方法,来维护一个全局当前的RT变量。虽然下面的方法看起来一团乱麻,但和上面一开始Hook Present的套路完全一样
//RT数怎么也大不过8吧?如果大过也没事,反正不会频繁分配空间
std::vector<ID3D11RenderTargetView*> g_ppRenderTargetView(8);
using vfn_DeviceContext_OMSetRenderTargets = void(WINAPI*)(ID3D11DeviceContext*,
__in_range(0, D3D11_SIMULTANEOUS_RENDER_TARGET_COUNT) UINT NumViews,
__in_ecount_opt(NumViews) ID3D11RenderTargetView* const* ppRenderTargetViews,
__in_opt ID3D11DepthStencilView* pDepthStencilView);
vfn_DeviceContext_OMSetRenderTargets oDeviceContext_OMSetRenderTargets = nullptr;
void WINAPI HookFuncDeviceContext_OMSetRenderTargets(ID3D11DeviceContext* pThis,
__in_range(0, D3D11_SIMULTANEOUS_RENDER_TARGET_COUNT) UINT NumViews,
__in_ecount_opt(NumViews) ID3D11RenderTargetView* const* ppRenderTargetViews,
__in_opt ID3D11DepthStencilView* pDepthStencilView)
{
g_ppRenderTargetView.clear();
for (int i = 0; i < NumViews; ++i)
{
ID3D11RenderTargetView* pRenderTargetView = *(ppRenderTargetViews + i);
if (pRenderTargetView == nullptr)
continue;
g_ppRenderTargetView.push_back(pRenderTargetView);
}
return oDeviceContext_OMSetRenderTargets(pThis, NumViews, ppRenderTargetViews, pDepthStencilView);
}
bool HookDeviceContext_OMSetRenderTargets()
{
void** p_DeviceContext_VMT = g_pDeviceContextVMT;
oDeviceContext_OMSetRenderTargets = (vfn_DeviceContext_OMSetRenderTargets)p_DeviceContext_VMT[(UINT)ID3D11DeviceContextVMT::OMSetRenderTargets];
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&oDeviceContext_OMSetRenderTargets, HookFuncDeviceContext_OMSetRenderTargets);
DetourTransactionCommit();
return true;
}
有了RT就能得到资源(Resource),就有办法保存:
void CaptureFrame()
{
if (g_ppRenderTargetView.size() == 0)
return;
++gCaptureNum;
for (int i = 0; i < g_ppRenderTargetView.size(); ++i)
{
std::wstringstream wss;
wss << L"我的保存路径\\Image_"
<< gCaptureNum << "_RT" << i << ".dds";
ID3D11RenderTargetView* view = g_ppRenderTargetView[i];
if (view == nullptr)
continue;
ID3D11Resource* pSourceResource;
view->GetResource(&pSourceResource);
D3D11_RENDER_TARGET_VIEW_DESC rtvDesc;
view->GetDesc(&rtvDesc);
std::cout << "Resource DXGIFormat: " << magic_enum::enum_name<DXGI_FORMAT>(rtvDesc.Format) << std::endl;
HRESULT hr = D3DX11SaveTextureToFile(g_pContext, pSourceResource, D3DX11_IFF_DDS, wss.str().c_str());
if (SUCCEEDED(hr))
std::wcout << "Save To " << wss.str() << std::endl;
else
std::cout << "截图错误:" << hr << std::endl;
pSourceResource->Release();
}
}
我试过在某个新游戏(对现在来说)用BMP格式保存,可惜只有渲染UI时能正常保存,PNG也是,不过DDS格式竟然能正常保存。
然后是体力活,要Hook Context的DrawIndexed和DrawIndexed,如果有必要,还有Draw和DrawInstanced,这些方法中先调用DrawCall,然后调用CaptureFrame,这里我放下Hook DrawIndexed的事例:
using vfn_DeviceContext_DrawIndexed = void(STDMETHODCALLTYPE*)(ID3D11DeviceContext*, UINT, UINT, UINT);
vfn_DeviceContext_DrawIndexed oDrawIndexed = nullptr;
void STDMETHODCALLTYPE HookFuncDeviceContextDrawIndexed(ID3D11DeviceContext* Context,
UINT IndexCount,
UINT StartIndexLocation,
UINT BaseVertexLocation)
{
oDrawIndexed(Context, IndexCount, StartIndexLocation, BaseVertexLocation);
if (Capturing)
CaptureFrame();
}
bool HookDrawIndexed()
{
void** p_DeviceContext_VMT = g_pDeviceContextVMT;
oDrawIndexed = (vfn_DeviceContext_DrawIndexed)(p_DeviceContext_VMT[(UINT)ID3D11DeviceContextVMT::DrawIndexed]);
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&oDrawIndexed, HookFuncDeviceContextDrawIndexed);
DetourTransactionCommit();
return true;
}
全局空间函数 Hook
和上面基本同样的套路,我Hook CreateWindowExW:
using fn_CreateWindowExW = HWND(WINAPI*)(
DWORD, LPCWSTR, LPCWSTR, DWORD, int, int,
int, int, HWND, HMENU, HINSTANCE, LPVOID
);
fn_CreateWindowExW oCreateWindowExW = CreateWindowExW;
HWND WINAPI HookFuncCreateWindowExW(DWORD dwExStyle, LPCWSTR lpClassName,
LPCWSTR lpWindowName, DWORD dwStyle, int X, int Y, int nWidth, int nHeight,
HWND hWndParent, HMENU hMenu, HINSTANCE hInstance, LPVOID lpParam)
{
std::wcout << L"HookCreateWindowExW! WindowName: " << lpWindowName << std::endl;
std::cout << "X: " << X << ", Y:" << Y << ", width: " << nWidth << ", height: " << nHeight << std::endl;
auto res = oCreateWindowExW(
dwExStyle, lpClassName, lpWindowName, dwStyle, X, Y, nWidth, nHeight,
hWndParent, hMenu, hInstance, lpParam);
return res;
}
bool HookCreateWindowExW()
{
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&oCreateWindowExW, HookFuncCreateWindowExW);
DetourTransactionCommit();
return true;
}
嗯,这是能运作的,可惜我Hook ID3D11CreateDevice和ID3D11CreateDeviceAndSwapChain时获取不到,或许是做了Hook保护?还是因为CreateWindowExW是kernel32.dll,地址空间所有进程一样?
我不清楚,但用了另一种方法成功了:
oD3D11CreateDevice =
(fn_D3D11CreateDevice)GetProcAddress(GetModuleHandle(_T("d3d11.dll")), "D3D11CreateDevice");
我可以用这个开启Debug Layer:
HRESULT WINAPI HookFuncD3D11CreateDevice(
_In_opt_ IDXGIAdapter* pAdapter,
D3D_DRIVER_TYPE DriverType,
HMODULE Software,
UINT Flags,
_In_reads_opt_(FeatureLevels) CONST D3D_FEATURE_LEVEL* pFeatureLevels,
UINT FeatureLevels,
UINT SDKVersion,
_COM_Outptr_opt_ ID3D11Device** ppDevice,
_Out_opt_ D3D_FEATURE_LEVEL* pFeatureLevel,
_COM_Outptr_opt_ ID3D11DeviceContext** ppImmediateContext
)
{
std::cout << "Hook D3D11CreateDevice!" << "Flag: " << Flags << std::endl;
if (Flags == 1)
Flags |= D3D11_CREATE_DEVICE_DEBUG;
HRESULT hr = oD3D11CreateDevice(pAdapter, DriverType, Software, Flags,
pFeatureLevels, FeatureLevels, SDKVersion, ppDevice,
pFeatureLevel, ppImmediateContext
);
return hr;
}
总结
通过这样的方法,我hook到游戏中并截帧,不过这个方法有很多缺陷,例如那个游戏在渲染数多时,有1500左右DrawCall,因为用了延迟管线,不少DrawCall都是MRT,最终保存的RT数要乘上3、4倍的DrawCall数,14G左右,尽管是三星SSD硬盘,也运行了3-5分钟,保存下来的DDS图片也未必是都能看的,VisualStudio和RenderDoc各能读取一些。
注入器的编写也碰到过一些问题,通过把注入器改成系统文件名解决了……
如果有办法,我还是想通过注入RenderDoc的方式分析并截帧,可惜现在注入后,能在dll列表中看到renderdoc.dll,但并没有起作用,或许我要去阅读一下RenderDoc的源码。
相关注入器代码: crossous/RemoteInject。