简介:这份资源是面向游戏逆向工程与图形编程学习者的C++ Direct3D Hook源码工程,核心围绕D3D函数拦截与游戏透视效果的实现展开。作者snakezl3通过API Hook方式捕获CreateDevice、Present等关键渲染函数,在自定义钩子中动态修改视图矩阵与投影矩阵,从而在不改动游戏原始代码的前提下实现透视,涉及VTable Hook、3D数学与图形管线等知识点,适合具备一定C++与D3D基础的中高级开发者研究。压缩包共71个文件,约67.5MB,以cpp与h源码文件为主体,辅以vcxproj、sln等VS工程配置、obj、pdb、ilk等编译中间产物,以及exe、dll、lib等可执行与库文件,另有rc、ico等资源文件,工程结构完整可直接编译调试。目前已有310人学习下载。通过研读源码,读者可掌握D3D Hook的完整实现流程、透视矩阵的计算思路与Hook的挂载及卸载时机,是理解游戏逆向与3D图形调试的实用参考,使用时请遵循合法合规原则。
1. 从 D3DHOOK.zip 说起:C++ 里 D3D Hook 透视到底在做什么
很多人第一次看到「D3DHOOK.zip_C++ D3DHOOK源码_D3D Hook透视」这类标题,脑子里冒出来的画面是打开一个压缩包、编译一下、进游戏就能看到墙后的人。实际做过的人都知道,事情没这么简单,但也没玄学到完全摸不着边。它本质上是三件事叠在一起:用 C++ 写一个 DLL,把 Direct3D 的渲染流程接管一段,再在每一帧画完场景之后,把原本不该被看见的目标用另一种方式重新画一遍。所谓「透视」,在 D3D Hook 这个语境里,绝大多数实现走的是「读取实体坐标 → 世界坐标转屏幕坐标 → 在屏幕空间叠加绘制」这条路,而不是去改游戏内存里的可见性标志。
这套东西适合谁?适合已经能写 C++、懂一点 Windows 消息循环和 COM 接口、想搞清楚「游戏画面是怎么被第三方代码插进去的」这类问题的从业者。它不适合把 C++ 当第一门语言的人,因为你会同时面对指针、虚表、调用约定、渲染管线四座山。但只要你写过结构体链表、用过回调函数,剩下的就是耐心问题。下面我按「先立住原理、再动手复现、最后讲坑」的顺序,把这条链路拆开讲清楚,中间会给出可以直接抄的代码骨架和参数说明。
2. D3D Hook 的三条主流路线与选型理由
2.1 虚函数表 Hook:为什么它是最常见的入口
Direct3D 9 的设备对象IDirect3DDevice9是一个 COM 接口,它的所有方法都通过虚函数表(vtable)暴露。所谓 vtable Hook,就是拿到这个对象的虚表指针,找到EndScene或Present对应的槽位,把里面的函数地址替换成我们自己的函数地址。游戏每帧都会调用EndScene,于是我们的代码每帧都会被顺带执行一次。这是最经典、资料最多、也最容易理解的一种方式。
为什么选EndScene而不是Present?因为EndScene在 D3D9 里是「场景绘制结束、准备提交」的时机,此时后台缓冲已经画完,我们在它之后叠加绘制,内容会直接出现在这一帧里。Present也可以,但它涉及交换链,某些多交换链的游戏里Present会被调用多次,容易重复绘制。常见做法是优先 HookEndScene,如果游戏用的是 D3D11 或 D3D12,那就要换成Present配合交换链的 vtable,思路一样,槽位不同。
选型上还有两条路:一条是直接改游戏内存里的函数头(inline hook / detour),另一条是注入后替换整个 DLL。前者对代码段有写保护,需要改内存属性,容易被反作弊盯上;后者动静大,但实现简单。vtable Hook 介于两者之间,改的是虚表里的一个指针,不改原始函数体,相对干净。我一般会先试 vtable,不行再考虑 detour。
2.2 世界坐标转屏幕坐标:透视的数学核心
Hook 只是「拿到每帧的执行权」,真正让透视成立的是坐标变换。游戏里每个角色、载具、物资都有一个世界坐标(x, y, z),而屏幕是二维的。要把世界坐标画到屏幕上,需要经过「世界矩阵 → 视图矩阵 → 投影矩阵」三次变换,最后做透视除法。D3D9 里这套矩阵通常可以通过GetTransform拿到,或者从游戏自己的渲染状态里读。
核心公式是:先把世界坐标乘上视图投影矩阵,得到一个四维齐次坐标,再用w分量做除法,得到归一化设备坐标(NDC),最后映射到屏幕像素。下面这段代码是常见的实现骨架,参数含义我会逐行说明。
// 世界坐标转屏幕坐标,D3D9 常用实现 bool WorldToScreen(const D3DXVECTOR3& world, D3DXVECTOR3& screen, const D3DXMATRIX& view, const D3DXMATRIX& proj, float screenW, float screenH) { // 1. 世界坐标扩展成四维,w 初始为 1 D3DXVECTOR4 pos(world.x, world.y, world.z, 1.0f); // 2. 先乘视图矩阵,再乘投影矩阵,得到裁剪空间坐标 D3DXMATRIX viewProj = view * proj; D3DXVec4Transform(&pos, &pos, &viewProj); // 3. w 小于等于 0 说明点在相机背后,直接丢弃 if (pos.w <= 0.01f) return false; // 4. 透视除法,得到 NDC,范围 [-1, 1] float ndcX = pos.x / pos.w; float ndcY = pos.y / pos.w; // 5. 映射到屏幕像素,Y 轴要翻转 screen.x = (ndcX + 1.0f) * 0.5f * screenW; screen.y = (1.0f - ndcY) * 0.5f * screenH; screen.z = pos.w; // 保留深度,用于排序 return true; }逻辑说明:第 2 步把视图和投影合并成一个矩阵再乘,减少一次矩阵乘法;第 3 步的w <= 0.01f是必须的,否则相机背后的点会被错误地投影到屏幕上,出现「鬼影」。第 5 步的 Y 轴翻转是因为 NDC 的 Y 向上,而屏幕坐标 Y 向下。参数screenW、screenH一般取游戏窗口的客户区宽高,不是显示器分辨率,这一点后面避坑章节会展开。
2.3 从坐标到方框:绘制阶段的最小实现
拿到屏幕坐标后,透视的呈现方式有很多种:画方框、画骨骼、画射线、画血条。最基础的是方框,因为只需要两个点。常见做法是用目标的世界坐标加上一个高度偏移,得到「头顶」和「脚底」两个世界点,分别转屏幕坐标,然后画矩形。下面是一个用 D3D9 画线框的简化函数。
// 在 EndScene 之后绘制一个矩形框 void DrawBox(LPDIRECT3DDEVICE9 device, float x, float y, float w, float h, D3DCOLOR color) { // 顶点格式:变换后的屏幕坐标 + 颜色 struct Vertex { float x, y, z, rhw; D3DCOLOR color; }; Vertex v[4] = { { x, y, 0.0f, 1.0f, color }, { x + w, y, 0.0f, 1.0f, color }, { x + w, y + h, 0.0f, 1.0f, color }, { x, y + h, 0.0f, 1.0f, color }, }; // 关闭光照和剔除,保证线框一定可见 device->SetRenderState(D3DRS_LIGHTING, FALSE); device->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE); device->SetFVF(D3DFVF_XYZRHW | D3DFVF_DIFFUSE); device->DrawPrimitiveUP(D3DPT_LINESTRIP, 3, v, sizeof(Vertex)); }逻辑说明:D3DFVF_XYZRHW表示顶点已经是屏幕坐标,不需要再经过变换管线,rhw是齐次 w 的倒数,固定填 1.0f。DrawPrimitiveUP用 LINESTRIP 画三条边,最后一条边靠首尾闭合,所以顶点数传 3。参数color用D3DCOLOR_ARGB构造,比如D3DCOLOR_ARGB(255, 255, 0, 0)是纯红。注意每帧绘制前要保存并恢复渲染状态,否则会把游戏自己的画面搞花,这是血泪经验。
3. 用 C++ 把 D3DHOOK 跑起来的最小工程
3.1 工程配置:DLL 类型、字符集与链接库
新建工程时选「动态链接库(DLL)」,不要选控制台。字符集建议用「多字节字符集」,因为 D3D9 的很多老接口和示例代码是按多字节写的,用 Unicode 会多出一堆LPCWSTR转换。链接库需要加d3d9.lib和d3dx9.lib,前者提供设备接口,后者提供矩阵和向量运算。如果你用的是 Visual Studio,在「项目属性 → 链接器 → 输入 → 附加依赖项」里加上这两个即可。
入口函数用DllMain,在DLL_PROCESS_ATTACH时创建一条线程去初始化 Hook。不要在DllMain里直接做复杂操作,因为加载锁的存在,容易死锁。常见做法是CreateThread后立刻返回,让 Hook 在独立线程里完成。
// dllmain.cpp #include <windows.h> DWORD WINAPI InitThread(LPVOID param) { // 在这里做 Hook 初始化,比如创建临时 D3D 设备拿虚表 // 具体实现见下一节 return 0; } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) { if (reason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); CreateThread(nullptr, 0, InitThread, nullptr, 0, nullptr); } return TRUE; }逻辑说明:DisableThreadLibraryCalls关闭线程创建/销毁通知,减少不必要的调用。CreateThread的第五个参数传 0 表示立即运行。参数hModule是当前 DLL 的句柄,后面如果要做卸载或资源定位会用到。
3.2 拿到 IDirect3DDevice9 虚表的两种办法
要 HookEndScene,先得拿到设备对象的虚表地址。有两种常见办法。第一种是创建一个临时的 D3D9 设备,从它的虚表里读出EndScene的地址,然后把这个地址作为「特征」去游戏进程里搜索。第二种是直接 HookIDirect3DDevice9::Present或Direct3DCreate9,等游戏自己创建设备时再替换。第一种更主动,第二种更稳。
下面演示第一种:创建一个隐藏窗口和临时设备,读出虚表第 42 号槽位(EndScene在 D3D9 虚表中的索引是 42)。
// 获取 EndScene 在虚表中的地址 typedef HRESULT(WINAPI* EndScene_t)(LPDIRECT3DDEVICE9); EndScene_t GetEndSceneAddress() { // 创建隐藏窗口 HWND hwnd = CreateWindowA("STATIC", "", WS_POPUP, 0, 0, 1, 1, nullptr, nullptr, nullptr, nullptr); IDirect3D9* d3d = Direct3DCreate9(D3D_SDK_VERSION); D3DPRESENT_PARAMETERS pp = {}; pp.Windowed = TRUE; pp.SwapEffect = D3DSWAPEFFECT_DISCARD; pp.hDeviceWindow = hwnd; IDirect3DDevice9* device = nullptr; d3d->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &pp, &device); // 虚表指针在对象的前 4 字节(32 位)或前 8 字节(64 位) void** vtable = *reinterpret_cast<void***>(device); EndScene_t addr = reinterpret_cast<EndScene_t>(vtable[42]); device->Release(); d3d->Release(); DestroyWindow(hwnd); return addr; }逻辑说明:vtable[42]是 D3D9 头文件里EndScene的固定索引,不同 SDK 版本可能有细微差别,但 42 是通用值。参数D3DCREATE_SOFTWARE_VERTEXPROCESSING是为了兼容性,实际游戏里可能用硬件顶点处理,但这里只是拿地址,不影响。注意 64 位下指针是 8 字节,reinterpret_cast<void***>的写法要相应调整。
3.3 替换虚表指针并写自己的 EndScene
拿到地址后,把虚表里那一项改成我们自己的函数地址。为了能调用原始函数,需要保存原地址。下面是一个完整的 Hook 骨架。
EndScene_t oEndScene = nullptr; // 保存原始 EndScene HRESULT WINAPI hkEndScene(LPDIRECT3DDEVICE9 device) { // 先让游戏自己画完这一帧 HRESULT hr = oEndScene(device); // 在这里做透视绘制:遍历实体、转屏幕坐标、画框 // DrawBox(device, ...); return hr; } void InstallHook() { EndScene_t target = GetEndSceneAddress(); oEndScene = target; // 修改虚表内存属性为可写 DWORD oldProtect; VirtualProtect(&((void**)target)[42], sizeof(void*), PAGE_EXECUTE_READWRITE, &oldProtect); ((void**)target)[42] = reinterpret_cast<void*>(hkEndScene); VirtualProtect(&((void**)target)[42], sizeof(void*), oldProtect, &oldProtect); }逻辑说明:VirtualProtect把虚表那一页改成可读写,改完再改回去,避免长期暴露写权限。hkEndScene里先调用oEndScene,保证游戏画面正常,再叠加自己的绘制。参数device是游戏传进来的设备指针,直接拿来画线即可。注意不要在hkEndScene里做耗时操作,否则帧率会掉。
4. 避坑与排查:D3D Hook 透视最常见的五个翻车点
4.1 现象:注入后游戏直接闪退,没有任何报错
原因:最常见的是在DllMain里做了重操作,或者 Hook 时机太早,游戏还没创建设备就去改虚表,导致访问了空指针。另一个原因是 32 位 DLL 注入到了 64 位进程,或者反过来。
解决:把初始化逻辑全部放到独立线程里,并且加一个循环等待,直到GetEndSceneAddress返回非空再继续。注入前确认目标进程的位数,用对应的编译配置。如果还是闪退,用调试器附加,看崩溃在哪个模块。
4.2 现象:方框画出来了,但位置整体偏移,或者只在屏幕左上角
原因:屏幕宽高取错了。很多人直接用GetSystemMetrics(SM_CXSCREEN),那是显示器分辨率,不是游戏窗口的客户区。游戏可能窗口化运行,或者有黑边,客户区尺寸和显示器尺寸不一致。
解决:用GetClientRect拿窗口客户区,再配合ClientToScreen换算。如果游戏用了缩放,还要考虑 DPI 感知。参数上,screenW和screenH应该等于客户区宽高,而不是分辨率。
4.3 现象:目标在相机背后时,方框出现在屏幕另一侧
原因:世界坐标转屏幕坐标时没有判断w分量。相机背后的点经过透视除法会得到一个「镜像」的坐标,看起来就像穿到了另一边。
解决:在WorldToScreen里加if (pos.w <= 0.01f) return false;,并且调用方要检查返回值,返回 false 就不画。这个 0.01f 是经验值,太小会漏掉近处目标,太大会误杀。
4.4 现象:游戏画面变花,UI 闪烁,或者帧率骤降
原因:绘制时改了渲染状态没恢复。D3D9 的状态是全局的,你关了光照、改了剔除模式,游戏后面的绘制就会受影响。另一个原因是每帧遍历实体太多,或者每帧都重新创建顶点缓冲。
解决:在hkEndScene开头保存关键状态,结尾恢复。常见要保存的有D3DRS_LIGHTING、D3DRS_CULLMODE、D3DRS_ZENABLE、SetFVF和纹理。顶点数据用栈上的数组,不要每帧new。如果实体多,做距离裁剪,只画视野内的。
4.5 现象:换了一台机器或换了个游戏版本,Hook 失效
原因:虚表索引变了,或者游戏用了 D3D11/12,EndScene根本不存在。另一个原因是游戏加了完整性校验,发现虚表被改就重置。
解决:先确认游戏的渲染 API,D3D9 用EndScene,D3D11 用Present配合IDXGISwapChain。如果是校验,考虑用更隐蔽的 detour,或者 Hook 更底层的函数。参数上,D3D11 的Present在交换链虚表的第 8 号槽位,和 D3D9 完全不同,不能混用。
5. 进阶技巧:让透视更稳、更准、更难被察觉
5.1 用矩阵缓存减少每帧开销
每帧都调用GetTransform拿矩阵是有开销的,而且有些游戏在EndScene时矩阵已经切回了 UI 矩阵。更稳的做法是在 Hook 到SetTransform时缓存视图和投影矩阵,绘制时直接用缓存。下面是一个缓存结构。
struct MatrixCache { D3DXMATRIX view; D3DXMATRIX proj; bool valid; }; MatrixCache g_cache = {}; // Hook SetTransform,缓存视图和投影 HRESULT WINAPI hkSetTransform(LPDIRECT3DDEVICE9 device, D3DTRANSFORMSTATETYPE state, const D3DXMATRIX* matrix) { if (state == D3DTS_VIEW) g_cache.view = *matrix; if (state == D3DTS_PROJECTION) g_cache.proj = *matrix; g_cache.valid = true; return oSetTransform(device, state, matrix); }逻辑说明:D3DTS_VIEW和D3DTS_PROJECTION分别对应视图和投影矩阵。缓存后,WorldToScreen直接用g_cache.view和g_cache.proj,不用再问设备要。参数state是变换类型,D3D9 里还有D3DTS_WORLD,但世界矩阵每个物体不同,一般不缓存。
5.2 用深度排序避免方框重叠错乱
多个目标在屏幕上重叠时,后画的会盖住先画的。如果想让近处的目标盖住远处的,需要按深度排序。WorldToScreen返回的screen.z就是w分量,可以当深度用。把目标收集到一个数组里,按z从小到大排序,再依次绘制。
| 参数 | 含义 | 建议值 |
|---|---|---|
| screen.z | 透视除法前的 w,近似距离 | 越小越近 |
| 排序方式 | 升序 | 近处先画,远处后画 |
| 最大绘制数 | 每帧最多画多少个 | 32~64,视性能而定 |
5.3 验证 Hook 是否生效的三个手段
第一,在hkEndScene里加一个计数器,用OutputDebugString每 60 帧打印一次,看调试器里有没有输出。第二,画一个固定的屏幕中心十字,如果能看到,说明绘制链路通了。第三,用D3DCOLOR_ARGB(255, 0, 255, 0)画一个绿色方框在固定屏幕坐标,确认颜色和位置都对。这三个手段按顺序做,能快速定位是 Hook 没生效、还是坐标算错、还是绘制状态有问题。
我自己做这类东西的习惯是:先把 Hook 跑通,画一个不依赖任何游戏数据的固定方框;再把坐标转换跑通,画一个已知世界坐标的点;最后才去遍历实体。跳过前两步直接上实体,翻车了根本不知道是哪一层的问题。希望帮到你。
本文还有配套的精品资源,点击获取