一句话总结:本篇补上绘制前的收尾数学拼图——读视图矩阵,用 WorldToScreen 把三维世界坐标换算成屏幕像素,然后真正画出透视方框、血条和距离文字,上两篇攒的玻璃和数据在这里合体。
前两篇解决了一个问题:拿到敌人的世界坐标,形如 (x=1823.5, y=-402.1, z=96.0)。但你的屏幕是 1920×1080 的二维像素平面,直接把 x 当横坐标用是行不通的。中间差的那一步叫投影,游戏圈管这个函数叫 WoRLdToScreen,简称 W2S。
投影的数学:四步走完
游戏引擎渲染画面时,把三维世界「拍平」到屏幕靠的是一个 4×4 矩阵,通常叫视图投影矩阵(ViewMatrix)。它已经是「视图 × 投影」的合成结果,我们拿来就用。虚构偏移:dwViewMatrix = base + 0x1A2C000, float[16]。
W2S 的完整流程四步:
图 1:从世界坐标到屏幕像素的四步投影链,任何一步除以零或 w 为负都要丢弃
用代码说话最清楚:
struct Matrix4x4 { float m[4][4]; }; // 行主序,游戏里也可能是列主序,画歪了就转置试试
bool WorldToScreen(const Vec3& pos, Vec2& out,
const Matrix4x4& vm, int w, int h) {
// 1. 点乘:世界坐标变裁剪坐标
float clipX = pos.x * vm.m[0][0] + pos.y * vm.m[0][1]
+ pos.z * vm.m[0][2] + vm.m[0][3];
float clipY = pos.x * vm.m[1][0] + pos.y * vm.m[1][1]
+ pos.z * vm.m[1][2] + vm.m[1][3];
float clipW = pos.x * vm.m[3][0] + pos.y * vm.m[3][1]
+ pos.z * vm.m[3][2] + vm.m[3][3];
// 2. 背面剔除:在相机身后,画了也是错的
if (clipW < 0.1f) return false;
// 3. 透视除法:裁剪坐标 → NDC(-1 到 1)
float ndcX = clipX / clipW;
float ndcY = clipY / clipW;
// 4. 视口变换:NDC → 像素
out.x = (w / 2) + ndcX * (w / 2);
out.y = (h / 2) - ndcY * (h / 2); // 屏幕 Y 轴朝下,要翻
return true;
}
三个高频翻车点直接告诉你。第一,clipW < 0 时点在相机背后,除以负数会得到镜像坐标,看着像画对了位置其实人在你身后——必须丢弃。第二,矩阵的行主序列主序因引擎而异,画出来满屏乱线就先把 m[i][j] 的下标换过来试。第三,屏幕 Y 轴朝下,NDC 的 Y 朝上,到头来少个减号就全错,整个画面会上下颠倒。
读矩阵 + 开画:三段拼一起
把矩阵读到本地(每帧一次就够,别在循环里反复读):
Matrix4x4 vm = {};
RPM(hProc, base + 0x1A2C000, &vm);
方框不用骨骼也能画个七七八八:拿脚底坐标和「头坐标」(脚底 z 加身高,虚构靶场玩家身高 72 单位),两个点各投影一次,头点 y 减脚点 y 就是框高,框宽取框高的三分之一:
void DrawBox(const Entity& e, const Matrix4x4& vm,
ID2D1RenderTarget* rt, ID2D1SolidColorBrush* br) {
Vec3 foot = e.pos;
Vec3 head = { e.pos.x, e.pos.y, e.pos.z + 72.0f };
Vec2 sFoot, sHead;
if (!WorldToScreen(foot, sFoot, vm, 1280, 720)) return;
if (!WorldToScreen(head, sHead, vm, 1280, 720)) return;
float boxH = sFoot.y - sHead.y; // 负值说明点序反了
if (boxH < 2.0f) return; // 太远,画了也是一坨
float boxW = boxH / 3.0f;
D2D1_RECT_F r = D2D1::RectF(
sHead.x - boxW / 2, sHead.y,
sHead.x + boxW / 2, sFoot.y);
rt->DrawRectangle(r, br, 1.6f);
}
血条挂在方框左边,文字挂头顶。距离用两点坐标差开平方,乘个场景缩放系数换算成米:
void DrawInfo(const Entity& e, Vec2 sHead, float boxH,
ID2D1RenderTarget* rt,
ID2D1SolidColorBrush* br, IDWriteTextFormat* tf) {
// 血条:宽 4px,高度按血量百分比
float barH = boxH * (e.hp / 100.0f);
D2D1_RECT_F bar = D2D1::RectF(
sHead.x - boxH / 6.0f - 8, sHead.y + (boxH - barH),
sHead.x - boxH / 6.0f - 4, sHead.y + boxH);
rt->FillRectangle(bar, br);
// 距离文字
wchar_t buf[32];
swprintf(buf, 32, L"%.0fm", e.dist);
rt->DrawTextW(buf, (UINT32)wcslen(buf), tf,
D2D1::RectF(sHead.x - 30, sHead.y - 22,
sHead.x + 30, sHead.y - 2), br);
}
颜色按阵营分:敌人红、队友绿,判定就是上一篇读的 m_iTeamNum 跟本地玩家比。渲染循环里遍历上一篇的实体快照,逐个 WorldToScreen + DrawBox,编译运行——方框第一次「贴」在目标身上的那一刻,前两篇的所有铺垫就都值回来了。
效果不对时的排查顺序
按命中率排:先查 clipW 剔除(背后的人画了一堆镜像框),再查矩阵行主列主(满屏乱线),再查 Y 翻转(画面上下颠倒),再查 DPI(框整体偏移一截)。这个顺序是从「错得离谱」到「错得微妙」排的,跟着查能省一半时间。
下一篇预告
功能通了,但现在的程序是个黑盒,没法交互。收尾篇把 ImGui 接进来:菜单、滑条、颜色选择器,再加配置保存和自动跟窗,让它从「能跑的 demo」变成「像样的工具」。顺便聊聊防检测面的话题——知道检测者怎么看这类程序,你才明白为什么靶场练习是唯一正确的打开方式。

发表评论
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。