☰
D3D11 Hook实战:拦截Present实现Overlay叠加与图形调试
2026/10/13 11:27:49 网站建设 项目流程

简介:这是一套使用C++语言编写的D3D11 Hook源码工程,同时支持32位与64位系统,面向Windows平台下从事图形API Hook、逆向分析或游戏渲染定制的开发者。工程将注入器、DLL主模块和Hook核心分目录组织,并且集成了BeaEngine反汇编引擎,能让使用者清晰看到从进程注入、函数挂钩到渲染重定向的完整技术路径。资源以rar压缩包发布,共84个文件,大小4.35MB。文件类型中既有Visual Studio的解决方案和工程文件,也有C++的头文件与源文件,同时包含编译过程留下的中间文件、构建日志以及BeaEngine针对多种语言编写的绑定文件。其中还提供了32位与64位两套反汇编引擎库,便于对照不同架构下的差异。目前已有2676人学习下载。通过这一工程,可以掌握D3D11 Hook的常见挂接思路、多平台编译配置方法、反汇编引擎的调用接口,以及注入器与Hook DLL的协作机制;尤其是BeaEngine的引入,让指令级调试和Hook偏移计算更加直观,适合有一定C++和Windows编程基础,准备深入图形Hook细节或开展游戏辅助技术研究的读者。

1. D3D11 Hook 源码能解决什么:把 Present 变成你的调试画板

如果你已经在做帧率叠加、画面截图或者画质增强这类工具,却总在“画面提交前的最后一刻”插不进自己的代码,D3D11 Hook 源码要补的就是这个缺口。这份源码正面拦截IDXGISwapChain::Present,在交换链翻转之前让你拿到后缓冲并绘制自己的叠加层,同时完整支持 X86 和 X64 两套架构。适合有 C++ 基础、想直接拿一套能编译能跑的框架再按需改的从业者;新手按步骤把 DLL 注入到自己的 Demo 程序后,看到屏幕上多出一个三角形,就算走通了。

2. 原理先立住:为什么关键在 Present 和 CreateRenderTargetView

2.1 Present 是交换链的心跳,也是插入绘制动作的唯一时机

Direct3D 11 的渲染结果并不是立即出现在屏幕上的。应用把几何、纹理、状态提交给命令列表,GPU 把结果写进若干后台缓冲区,等到一帧画完,IDXGISwapChain::Present(UINT SyncInterval, UINT Flags)会把已经画好的那块缓冲翻转或拷贝到前台显示。在你 hook 的视角里,Present 是一个边界:在此之前你可以读后缓冲、改着色器、画叠加层;在此之后缓冲区归属变成显示侧,你画上去的内容会被丢弃或造成闪烁。

那两个参数也别忽略。SyncInterval 为 0 表示不等待垂直同步直接翻转;非 0 数值表示等几个垂直同步信号。Flags 里如果带了DXGI_PRESENT_TEST,目标程序只是在测试能否呈现,这时你如果去绑定后缓冲并绘制,得到的画面不会真正上屏,还要白耗一次 GPU 同步。所以较新的源码里都会先判断 Flags,再决定要不要执行 Overlay 绘制。

这里顺手说个经验:接手一份不熟悉的 hook 源码,先别急着看代码逻辑,先在 MyPresent 里放一个计数器,每调用一次就++,然后输出到日志,确认“时机”是对的。很多所谓“hook 没生效”的问题,其实是你 hook 的函数根本不是 Present,或者索引错了。

2.2 vtable 索引和 inline hook:两条路线怎么选

定位 Present 的入口有两种做法。第一种是读 swapchain 对象的虚函数表。IDXGISwapChain继承自IDXGIObject,再往上才是IUnknown,接口方法按顺序排列,Present落在索引 8:

typedef HRESULT(STDMETHODCALLTYPE* PFN_Present)( IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags); static PFN_Present g_pOriginalPresent = nullptr; // pSwapChain 是已经创建好的交换链对象 DWORD_PTR* pVtable = *reinterpret_cast<DWORD_PTR**>(pSwapChain); g_pOriginalPresent = reinterpret_cast<PFN_Present>(pVtable[8]); // 用 MinHook 建立 inline hook MH_CreateHook( reinterpret_cast<LPVOID>(pVtable[8]), reinterpret_cast<LPVOID>(MyPresent), reinterpret_cast<LPVOID*>(&g_pOriginalPresent)); MH_EnableHook(reinterpret_cast<LPVOID>(pVtable[8]));

逻辑说明:先通过 swapchain 对象读取它的虚表指针,取出第 8 个槽位作为原 Present 地址;MH_CreateHook会把原函数入口的前几条指令改成跳转到MyPresent,同时把被覆盖的指令保存进g_pOriginalPresent,后续在MyPresent里调用g_pOriginalPresent就是干净地回到原逻辑。

参数说明:

  • pVtable[8]的索引是按 COM 接口顺序排下来的,不是拍脑袋数出来的,建议对照公开的 DXGI 头文件再核对
  • g_pOriginalPresent必须声明为static,否则 detour 库拿到的回源地址可能是栈上的临时拷贝
  • STDMETHODCALLTYPE在 x64 下和默认调用约定一致,但在 x86 下对应__stdcall,漏掉它会出现参数错位,这也是 x86 崩溃的常客

第二种路线是 vtable swap,也就是复制一张虚表,把第 8 项替换成 MyPresent,再把 swapchain 对象头部的虚表指针指向新表。它的优点是改的是每张表,不会改动全局函数入口;缺点是你得在每个新建的 swapchain 上都复制一份表,还要管理多张表的生命周期。相比之下,inline hook 只需要一个原函数地址,即使目标程序因为分辨率变化重建了交换链,同一个 Present 函数入口照样能命中。所以目前较新的 D3D11 hook 源码大多走 MinHook 这类 detour 路线,省心。

2.3 后缓冲与 CreateRenderTargetView:你改的到底是哪一格像素

拿到 Present 的时机之后,下一步是把后缓冲绑成渲染目标。D3D11 里不能直接拿着IDXGISwapChain画图,你得先GetBuffer(0)拿ID3D11Texture2D*,再CreateRenderTargetView包装成 RTV,再用OMSetRenderTargets绑进渲染管线。

这一段 MyPresent 的内部流程很典型:

HRESULT STDMETHODCALLTYPE MyPresent( IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { // 回取设备与上下文。这一步在真实工程里应该缓存,不应每帧都做 ID3D11Device* pDevice = nullptr; ID3D11DeviceContext* pContext = nullptr; pSwapChain->GetDevice(IID_PPV_ARGS(&pDevice)); pDevice->GetImmediateContext(&pContext); // 保存游戏原本绑定的 RTV ID3D11RenderTargetView* pOldRTV = nullptr; pContext->OMGetRenderTargets(1, &pOldRTV, nullptr); // 取后缓冲,创建临时 RTV ID3D11Texture2D* pBackBuffer = nullptr; ID3D11RenderTargetView* pTmpRTV = nullptr; if (SUCCEEDED(pSwapChain->GetBuffer(0, IID_PPV_ARGS(&pBackBuffer))) && SUCCEEDED(pDevice->CreateRenderTargetView(pBackBuffer, nullptr, &pTmpRTV))) { pContext->OMSetRenderTargets(1, &pTmpRTV, nullptr); RenderOverlay(pDevice, pContext, pSwapChain); pTmpRTV->Release(); } if (pBackBuffer) pBackBuffer->Release(); // 调用原 Present,让画面正常翻转 HRESULT hr = g_pOriginalPresent(pSwapChain, SyncInterval, Flags); // 画完后恢复原绑定,避免影响游戏后续帧 pContext->OMSetRenderTargets(1, &pOldRTV, nullptr); if (pOldRTV) pOldRTV->Release(); pContext->Release(); pDevice->Release(); return hr; }

逻辑说明:先保存当前 RTV,是为了让 Overlay 画完不收尾,游戏下一帧不会被你污染;GetBuffer(0)拿到的是后缓冲索引 0,也就是当前正在绘制的那一块;CreateRenderTargetView把它变成渲染目标,RenderOverlay在里面画自己的东西;最后必须调用原 Present 让交换链翻转,否则窗口会冻结。

几个决定成败的细节:

  • 覆盖层要在调用原 Present 之前画。画完之后再调 Present,缓冲区才会带着 Overlay 一起翻转
  • 每次把新旧 RTV 换进换出时留意引用计数,OMGetRenderTargets返回的pOldRTV带一个引用,用完要Release,泄漏多了之后目标程序会慢慢变得卡顿
  • 渲染期间把 viewport 重新设成整个窗口大小,有些游戏会缩小视口只画 UI 区域,容易让人觉得 Overlay 位置不对

原理部分到这里基本够用了。接下来要做的,是把这个流程放到编译环节。

3. 源码拆包与编译:X86/X64 双架构 DLL 的目录结构与构建步骤

3.1 资源包目录与文件分工

一个典型的 D3D11 hook 源码包解压后,通常长这样(不同源码有细微差别,但模块划分是相似的):

路径作用通常要改吗
src/d3d11hook.cpp/.h核心 hook 逻辑,初始化 MinHook、定义 MyPresent改绘制前准备用
src/overlay.cppOverlay 绘制实现,画三角形、字体、纹理每次都改
src/config.cpp/.h读取配置文件的封装一般不改
src/minhook/MinHook 库源文件,x86/x64 通用不用改
injector/注入工具源码,负责把 DLL 送进目标进程按进程名改
config.ini运行时参数:目标进程名、颜色、热键必改
build/x86和build/x64预编译好的 DLL 和注入器用预编译就不动

先摸清路径再动手是最省时间的。重点看d3d11hook.cpp里MyPresent的内容,那是所有机制发生的地方;再看overlay.cpp暴露的绘制回调,Hook 框架和绘制逻辑是分开的,好源码一定会把这两层拆开,方便你不影响 hook 结构单独替换 UI。

3.2 用 VS 分别产出 x86/x64 两个 DLL

构建分为两个平台,缺一不可:

MSBuild.exe D3D11Hook.sln /p:Configuration=Release /p:Platform=Win32 MSBuild.exe D3D11Hook.sln /p:Configuration=Release /p:Platform=x64

参数说明:Configuration=Release不要贪方便用 Debug 去测,Debug 版运行库依赖和异常处理路径不同,注入后容易在目标进程里产生假象;Platform=Win32对应 32 位 DLL,即使系统是 64 位也要单独产出这一份;x64平台则产出 64 位 DLL。

构建前,在 VS 的项目属性里确认四个点:

  • 字符集选“使用 Unicode 字符集”,源码里进程名和路径大多以宽字符存在
  • 运行库用/MT(多线程静态),这样 DLL 不依赖目标进程是否装了 VC++ 运行库
  • 平台工具集保持和解决方案一致,混用 v142/v143 有时会让 MinHook 的汇编代码生成差异,导致 x86 下崩溃
  • 输出目录建议分别为build/x86和build/x64,避免两个 DLL 同名互盖

如果遇到链接错误,比如unresolved external symbol,先检查是不是把 64 位的 MinHook 静态库链接到了 x86 工程里,或者反过来。这个问题等同于“架构不匹配”,不是代码逻辑问题。

3.3 注入方式与目标进程策略

注入器把 DLL 送进目标进程后,DLL 入口会跑初始化。通常需要做这几件事:

  1. 初始化 MinHook:MH_Initialize()
  2. 找到目标进程的 D3D11 设备上下文,或等待 swapchain 出现
  3. 读取 Present 地址并建立 hook
  4. 开启热键或标志,让 Overlay 可以随时开关

等待 d3d11.dll 加载这一步很关键。目标进程如果还没有创建任何 D3D11 设备,你就拿不到 swapchain 对象。常见的做法是轮询:

// DLL 入口里等待 d3d11.dll 被加载,超时后放弃 static bool WaitForD3D11Module() { for (int i = 0; i < 100; ++i) { if (::GetModuleHandleA("d3d11.dll") != nullptr) return true; ::Sleep(100); } return false; }

逻辑说明:每 100ms 检查一次 d3d11.dll 是否已进进程,最多等 10 秒。等不到说明目标程序可能不是 3D 应用,强行 hook 只会失败。

注入策略也要选对:

  • 对已有进程,用CreateRemoteThread + LoadLibrary注入 DLL,老牌且相对直观
  • 对随系统启动的进程,用 AppInit_DLLs 或注册表方式做全局注入,但副作用大,新源码一般只把它放在可选分支里
  • 如果是调试自己写的目标程序,直接在启动参数里让程序加载 DLL 更省事,也更容易定位问题

关于注入器的命令行参数,常见用法:

Injector.exe -p demo.exe -d "D:\build\x64\D3D11Hook.dll"

参数说明:-p指定进程名,-d指定 DLL 全路径。注入器先按进程名找到 PID,再用OpenProcess打开进程,申请内存写入 DLL 路径并创建远程线程。注入失败时优先看错误码,ERROR_FILE_NOT_FOUND大多是 DLL 路径不对;ERROR_ACCESS_DENIED则是权限不足,需要以管理员身份运行。

4. 把 Hook 接到窗口:Overlay 叠加、参数调整与多交换链适配

4.1 最小可跑通的 Overlay 回调

拿到源码后先别急着写花哨 UI,第一步是把 Overlay 回调跑出一个可见色块。用 D3D11 自带管线画一个三角形,代码量最小,也最能验证 hook 是否成功:

// overlay.cpp 中的最小绘制回调 void RenderOverlay(ID3D11Device* pDevice, ID3D11DeviceContext* pContext, IDXGISwapChain* pSwapChain) { // 初始化三角形资源:顶点缓冲 + shader static ID3D11Buffer* pVB = nullptr; static ID3D11VertexShader* pVS = nullptr; static ID3D11PixelShader* pPS = nullptr; if (!pVB) InitTriangleResources(pDevice, &pVB, &pVS, &pPS); UINT stride = sizeof(float) * 6; // 位置 3 + 颜色 3 UINT offset = 0; pContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); pContext->IASetVertexBuffers(0, 1, &pVB, &stride, &offset); pContext->VSSetShader(pVS, nullptr, 0); pContext->PSSetShader(pPS, nullptr, 0); pContext->Draw(3, 0); }

逻辑说明:InitTriangleResources里创建顶点缓冲和编译 shader,这里不展开是因为每个源码包提供的初始资源结构不同,但核心动作都一样;IASetVertexBuffers把顶点数据送进输入装配阶段,VSSetShader和PSSetShader绑定最小着色器,Draw(3,0)画出一个三角形。

注意:绘制前记得把视口设成窗口全尺寸:

D3D11_VIEWPORT vp; vp.TopLeftX = 0; vp.TopLeftY = 0; vp.Width = (FLOAT)backBufferWidth; vp.Height = (FLOAT)backBufferHeight; vp.MinDepth = 0.0f; vp.MaxDepth = 1.0f; pContext->RSSetViewports(1, &vp);

如果不设,顶点会被局部的视口裁剪,三角形可能只露出一角,这是很多人第一次跑 Overlay 时最容易觉得“没生效”的原因。

4.2 参数怎么调:颜色、字体、刷新率与配置文件

源码包一般都会带config.ini,目的是让你不改一行代码就能试参数。一个常见的最小配置:

[Overlay] process_name = demo.exe alpha = 180 font_size = 16 refresh_interval = 0.5 show_fps = 1 show_swapchain = 1
  • alpha:叠加层的透明度,0 全透明、255 不透明。做半透明 HUD 时 140~190 比较舒服
  • font_size:如果你用的是字体纹理渲染,这个参数控制字号;改用 ImGui 时它映射到io.FontGlobalScale,注意单位不一样
  • refresh_interval:Overlay 每秒最大刷新次数,写成 0.5 就是每 500ms 更新一次文本。别设太小的值,低于 0.05 会让字体纹理每帧重绘制,帧开销暴涨
  • show_swapchain:调试用,把它打开后把pSwapChain的指针以十六进制打在角落,多窗口程序里能一眼看出目标切换了没有

这些配置项在config.cpp里被读进一个全局结构体,RenderOverlay里只做判断,不要直接在回调里解析文件。这样把 IO 和渲染分离,是源码包希望你自己扩展时遵守的边界。

4.3 多交换链与多窗口几个要注意的适配点

如果目标程序是多窗口,或者支持全屏/窗口切换,单凭一个 swapchain 做初始化会漏事件。较新的源码包会做两件事:

其一,监听IDXGIFactory::CreateSwapChain或CreateSwapChainForHwnd,每次创建新交换链时把实例记进列表,同时确认 hook 目标已经是 Present 函数入口。这一步看起来多余,但能避免“第一个交换链被销毁后,第二个交换链没有被覆盖”的场景。

其二,把 Overlay 的渲染状态从“每帧创建释放”改成“缓存复用”。多交换链下每帧都要去拿 backbuffer 和 RTV,GPU 和 CPU 都会增加压力。正确做法是按窗口尺寸缓存 RTV,并在检测到尺寸变化时Release重建:

// 缓存 RTV 的辅助函数 ID3D11RenderTargetView* GetCachedRTV( ID3D11Device* pDevice, IDXGISwapChain* pSwapChain, UINT* lastW, UINT* lastH) { DXGI_SWAP_CHAIN_DESC desc; pSwapChain->GetDesc(&desc); // 尺寸变化说明重建了缓冲,必须重新拿 RTV if (*lastW != desc.BufferDesc.Width || *lastH != desc.BufferDesc.Height) { if (g_pCachedRTV) { g_pCachedRTV->Release(); g_pCachedRTV = nullptr; } *lastW = desc.BufferDesc.Width; *lastH = desc.BufferDesc.Height; } if (!g_pCachedRTV) { ID3D11Texture2D* pBackBuffer = nullptr; pSwapChain->GetBuffer(0, IID_PPV_ARGS(&pBackBuffer)); pDevice->CreateRenderTargetView(pBackBuffer, nullptr, &g_pCachedRTV); pBackBuffer->Release(); } return g_pCachedRTV; }

逻辑说明:用GetDesc拿到缓冲宽高,和上次记录比对,一旦变化就清理旧 RTV 再创建;这样在分辨率切换、全屏切换时能保住缓存有效,又不会每帧重复创建。

多窗口适配的额外建议:为每个 swapchain 记录一个独立 Overlay 开关和颜色配置,不要用一个全局bool去控制所有窗口。否则你关掉一个调试窗口,所有窗口的 Overlay 都会跟着消失。

5. 避坑记录:D3D11 Hook 最常见的五个翻车现场

这一章全部用“现象 → 原因 → 解决”的格式记录,都是实际对着源码包排查时踩过的坑。

5.1 Overlay 闪一下然后消失

现象:刚开始还能看到三角形,一秒钟后 overlay 消失了,目标程序自己的 UI 也开始闪烁。

原因:大多数情况下是OMGetRenderTargets保存的原 RTV 没有在绘制完成后恢复;或者恢复时把 pOldRTV 直接释放了,游戏下一帧再想绑定这块目标,引用计数已经不对。

解决:绘制结束后先OMSetRenderTargets(1, &pOldRTV, nullptr)再ReleasepOldRTV。顺序不能反,先恢复绑定、再释放引用。如果你每次都从OMGetRenderTargets拿原 RTV 并正常释放,这个问题基本会绝迹。

5.2 x86 目标进程直接崩溃、报错堆栈在 ntdll

现象:x64 下一切正常,一换 x86 目标进程就启动崩溃,异常地址经常在后台线程里,看起来像随机的。

原因:最常见的是函数指针类型没加STDMETHODCALLTYPE。x86 下的默认调用约定是__cdecl,而 COM 接口方法必须用__stdcall。参数从右往左压栈,返回时由被调方清栈,一旦约定不匹配,两个线程看到栈指针不一致,崩在很奇怪的位置也就不奇怪了。

解决:给所有Present、CreateSwapChain的函数指针加上STDMETHODCALLTYPE。这个宏在 x64 下没有实际差异,但在 x86 下就是命门。

5.3 全屏切换之后 hook 失效

现象:目标程序从窗口切到全屏,Overlay 没了,切回窗口又恢复。

原因:如果你的源码是 vtable swap 方案,每个 swapchain 实例的虚表是单独替换的;全屏切换往往导致 swapchain 重建,新实例的虚表还是原样,自然 hook 不到。

解决:改用 MinHook 这类 inline detour,因为它是改函数入口指令,跟 swapchain 实例无关;或者至少补充一个ResizeBuffers的 hook,在缓冲重建后重新绑定 RTV 和重新执行 hook。

5.4 叠加层加上后帧率掉得很明显

现象:不画 Overlay 时目标程序跑在 120 FPS,画上 Overlay 后掉到 60 FPS,而且 GPU 占用率也高了。

原因:每一帧都执行GetDevice、GetImmediateContext、GetBuffer、CreateRenderTargetView,这些 API 调用虽然没有很高的 GPU 开销,但 CPU 调用链和引用计数反复横跳会产生可感知的消耗;更隐蔽的是你每帧创建了 RTV 却没来得及释放,内存和显存对象堆积,触发额外的驱动内存回收。

解决:把 device / context / RTV 都缓存起来。设备、上下文在首次拿好后AddRef并长期持有,只有目标进程退出才释放;RTV 按缓冲尺寸缓存,尺寸变了再重建。

5.5 注入时报“模块未加载”但 DLL 确实在同目录

现象:注入器返回成功,但目标进程里查询模块列表没有 D3D11Hook.dll,Overlay 完全没跑。

原因:很可能注入器使用 32 位OpenProcess去打开 64 位进程,或者 DLL 路径传入的是相对路径。远程线程创建后,目标进程的工作目录未必是注入器所在目录,相对路径解析失败。

解决:注入器给LoadLibrary传绝对路径,不要传相对路径;同时确认注入器和目标进程位数匹配。排查时用CreateRemoteThread返回值结合GetLastError看,返回NULL且ERROR_MOD_NOT_FOUND就说明路径在进目标进程前就断了。

6. 把验证当成习惯:数字先行,再谈扩展

6.1 用计数器验证 Hook 是否真的生效

进入 MyPresent 后先别画任何图形,给模块加一个全局计数器,同时按帧写入日志文件:

extern "C" __declspec(dllexport) volatile ULONGLONG g_presentCount = 0; HRESULT STDMETHODCALLTYPE MyPresent(...) { ++g_presentCount; // 每隔 60 帧写一次日志,方便确认不是偶尔触发 if (g_presentCount % 60 == 0) LogDebug(L"%llu", g_presentCount); return g_pOriginalPresent(pSwapChain, SyncInterval, Flags); }

道理很简单:计数器持续递增,说明 Present 确实被我们接管;如果数字停滞但窗口仍然有画面,说明目标程序可能走了另一个 swapchain,或者你的 hook 查错了索引。我习惯在修改任何绘制代码之前,先跑五分钟计数器日志,把 hook 是否生效这个变量彻底钉死,再去调颜色和字体,不把“没生效”和“画得不对”混在一起。

6.2 再往深一点:截帧与着色器替换

验证完基础链路,可以往外扩。截帧是最实用的方向:在 MyPresent 里拿到 backbuffer 后,CopyResource到共享纹理,再把共享纹理映射回 CPU 读出像素,存成 png。这一步注意别在 Present 调用中间做阻塞映射,否则 GPU 流水线会被卡住,目标程序帧率立刻掉下来。常见做法是用一个异步线程配合共享资源,把拷贝和编码丢给后台。

着色器替换则是在 Hook 里再挂一个VSSetShader/PSSetShader的旁路,拦截目标程序绑定 shader 时换成自己的版本,实现画质特效调整。这个扩展点已经接近图形调试器的底层能力,但要注意每次替换都要保存原来的 shader 指针并恢复,否则目标程序下一帧渲染状态会错乱。

这次编译完 DLL 之后,我拿自己的 Demo 程序测了十几次,每次全屏切换之后都强制走一遍“计数器 → 三角形 → 字体 → 多交换链”的流程,确认每一步都对得上。到后来这已经成了条件反射:改任何绘制逻辑之前先看计数器,确认 hook 链路没断。希望这些经验对你自己拆这份资源也有用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询