当前位置:首页 > 编程开发 > 正文

C++ 游戏辅助绘制系列(三):WorldToScreen 投影,把三维坐标钉到屏幕上

约 4402 字 · 预计阅读 12 分钟
一句话总结:本篇补上绘制前的收尾数学拼图——读视图矩阵,用 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」变成「像样的工具」。顺便聊聊防检测面的话题——知道检测者怎么看这类程序,你才明白为什么靶场练习是唯一正确的打开方式。

目录导航
  • 投影的数学:四步走完
  • 读矩阵 + 开画:三段拼一起
  • 效果不对时的排查顺序
  • 下一篇预告
  • 相关文章:

  • C++ 游戏辅助进阶系列(四):速度预判与边界,给自瞄系列收个尾2026-10-20 00:49:58
  • C++ 游戏辅助进阶系列(三):写视角与骨骼瞄准,把自瞄真正打通2026-10-20 00:49:58
  • C++ 游戏辅助进阶系列(二):目标怎么选,FOV 筛选、权重排序与平滑防抖2026-10-20 00:49:58
  • C++ 游戏辅助进阶系列(一):自瞄的数学地基,从两个坐标算出该转多少度2026-10-20 00:49:58
  • C++ 游戏辅助绘制系列(四):ImGui 菜单与工程收尾,从 demo 到像样的工具2026-10-20 00:49:58
  • C++ 游戏辅助绘制系列(二):读内存,把「敌人在哪」变成几个函数调用2026-10-20 00:49:58
  • C++ 游戏辅助绘制系列(一):透明窗口与点击穿透,把画布架在游戏上面2026-10-20 00:49:58
  • 发表评论

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