尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

【模块实现 03】ImGui 游戏内菜单:DX12 渲染路径的完整落地

【模块实现 03】ImGui 游戏内菜单:DX12 渲染路径的完整落地
📅 发布时间:2026/6/24 5:53:27

很多资料里把游戏内注入的 ImGui UI 称作 “悬浮菜单”,容易让人误解成类似桌面悬浮窗的独立顶层窗口。实际上 upscalerBridge 这套菜单没有创建任何额外的系统窗口,而是通过 Hook 游戏渲染管线,把 UI 绘制指令直接插入到原生渲染流中,和游戏场景在同一个 GPU 渲染流程里混合输出。它本质是渲染管线内部的 Overlay 覆盖层,而非操作系统级的悬浮界面。

本文基于 DX12 路径,完整拆解这套菜单的架构设计、渲染接入点、资源管理和设置生效链路。

一、整体架构:双渲染路径,全管线内联

整套菜单系统分成三层架构,所有绘制都发生在游戏渲染进程内部,无外部窗口、无跨进程绘制:

  • 通用逻辑层(MenuCommon):和渲染 API 完全解耦,负责控件布局、主题样式、配置读写、输入处理、状态管理
  • 渲染适配层(Menu_Dx12 / MenuOverlayDx):DX12 资源创建、渲染目标管理、ImGui 绘制指令提交
  • 管线接入层:分别在超分渲染节点和 SwapChain Present 节点两个位置注入绘制

由Config::OverlayMenu配置项控制两条渲染路径,二者都属于 “管线内联”,只是接入时机不同:

表格

模式接入点绘制目标特点
非 Overlay 模式超分 Evaluate 管线末尾超分输出纹理UI 和超分结果深度融合,跟随场景走后续色调映射、后处理流程,真正 “和场景一起渲染”
Overlay 模式SwapChain Present 调用前交换链后台缓冲独立于超分管线,超分未初始化时也能显示菜单,是默认启用的主路径

两种模式共用同一套 MenuCommon 逻辑,仅渲染适配层有差异,保证设置项、交互体验完全一致。

二、通用逻辑层:和 API 无关的 UI 内核

这一层是纯 ImGui 业务逻辑,不关心底层是 DX12 还是 Vulkan,是菜单功能的核心载体。

1. 定制主题与 HDR 自适应

默认 ImGui 深色主题视觉偏简陋,这里通过统一的色彩混合体系重绘了整套样式:统一 2px 圆角、蓝色系主色调、深灰近黑底色,所有控件状态(常规 / 悬停 / 按下)都通过主色与底色按比例混合生成,视觉风格统一连贯。

更关键的是 HDR 自动适配:当游戏开启 HDR 时,UI 颜色如果直接输出会过曝发白。代码中通过 Reinhard 色调映射算法,自动对所有 ImGui 样式色做亮度压缩,同时保留原始 SDR 色值作为备份,SDR/HDR 切换时可无缝恢复:

// 源码位置:menu_common.cpp / 静态全局函数 toneMapColor / HDR环境下ImGui UI颜色自动执行Reinhard色调映射,避免过曝 static ImVec4 toneMapColor(const ImVec4& color) { if (State::Instance().isHdrActive || (!Config::Instance()->OverlayMenu.value_or_default() && State::Instance().currentFeature != nullptr && State::Instance().currentFeature->IsHdr())) { // Controls how strongly HDR/UI colors are pushed into the tone mapper before compression. // Higher values make colors brighter before mapping; lower values make the result dimmer. constexpr float exposure = 1.0f; // Blends between original color and fully tone-mapped color. // 0.0 = no tone mapping, 1.0 = full Reinhard compression. constexpr float strength = 1.0f; float peak = std::max(color.x, std::max(color.y, color.z)); if (peak <= 0.0f) return color; float exposedPeak = peak * exposure; float mappedPeak = exposedPeak / (1.0f + exposedPeak); float reinhardScale = mappedPeak / peak; float scale = 1.0f + (reinhardScale - 1.0f) * strength; return ImVec4(color.x * scale, color.y * scale, color.z * scale, color.w); } return color; }

2. 模块化功能分区

菜单内容按功能拆分成独立渲染函数,每个函数负责一块区域,按需调用,结构清晰易扩展:

  • 头部状态区:版本提示、超分未激活警告、更新通知 Toast
  • 超分设置区:后端选择、FSR/DLSS 专属参数、锐化调节
  • 帧生成区:FG 开关、HUD 修复、高级时序参数
  • 性能图表区:帧时间、超分耗时实时折线图
  • 底部操作区:UI 缩放、保存配置、关闭按钮

以后端选择下拉框为例,会自动根据 GPU 硬件能力过滤不支持的选项(A 卡自动隐藏 DLSS),避免用户选到无效后端:

// 源码位置:menu_common.cpp / MenuCommon::RenderUpscalerCombo / 超分后端下拉框渲染,按GPU能力过滤可选项 void MenuCommon::RenderUpscalerCombo(const API api, Upscaler currentUpscaler, const std::vector<Upscaler>& options) { auto primaryGpu = IdentifyGpu::getPrimaryGpu(); // Determine display name Upscaler targetBackend = State::Instance().newBackend; if (targetBackend == Upscaler::Reset) targetBackend = currentUpscaler; std::string selectedName = UpscalerDisplayName(targetBackend, api); if (ImGui::BeginCombo("##UpscalerCombo", selectedName.c_str())) { for (auto opt : options) { // Check if GPU is capable of a given backend if (opt == Upscaler::DLSS && !primaryGpu.dlssCapable) continue; bool isSelected = (currentUpscaler == opt); if (ImGui::Selectable(UpscalerDisplayName(opt, api).c_str(), isSelected)) { State::Instance().newBackend = opt; } } ImGui::EndCombo(); } }

3. 显隐与输入接管

菜单默认隐藏,通过全局快捷键(默认 Insert)呼出。隐藏时仅保留最基础的按键检测,几乎零性能开销;呼出时才执行完整 ImGui 绘制逻辑,同时接管键鼠输入,避免操作菜单时游戏同时响应按键、鼠标视角乱转。

全局快捷键做了 1000ms 防抖处理,防止单次按键被多次触发切换:

// 源码位置:input_system.cpp / OptiInput 命名空间 / UpdateManualInput 函数内 / 全局快捷键防抖逻辑 constexpr uint64_t debounceThreshold = 1000; const auto currentTick = GetTickCount64(); const bool canAcceptInputs = lastInputTick + debounceThreshold < currentTick; if (canAcceptInputs) { CheckShortcut(config->ShortcutKey.value_or_default(), inputMenu, "Menu key pressed, will be switching menu"); CheckShortcut(config->FGShortcutKey.value_or_default(), inputFG, "Menu key pressed, will be switching FG mode"); }

三、DX12 渲染适配:管线内联的核心细节

这是最容易踩坑的一层 ——DX12 下所有资源、状态都需要手动管理,插入绘制指令时不能破坏游戏原本的渲染状态,否则会直接导致设备移除、黑屏崩溃。

1. 两个接入点:真正的 “插入原渲染指令”

非 Overlay 路径:融入超分管线在IFeature_Dx12::Evaluate超分执行完成后,直接把 ImGui 绘制到超分输出纹理上。这条路径下 UI 和游戏场景画面完全融合,一起走后续的色调映射、后处理流程,是真正意义上的 “和场景一起渲染”。

Overlay 路径:Present 前回写这是默认启用的主路径,通过包装游戏的IDXGISwapChain::Present调用,在真正 Present 执行前,把 UI 绘制到交换链的后台缓冲上:

// 源码位置:menu_overlay_dx.cpp / MenuOverlayDx::Present / SwapChain Present调用前注入菜单绘制逻辑 // 实际由被Hook的IDXGISwapChain::Present在调用真实Present前触发,实现无额外窗口的管线内联绘制 void MenuOverlayDx::Present(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags, const DXGI_PRESENT_PARAMETERS* pPresentParameters, IUnknown* pDevice, HWND hWnd, bool isUWP) { if (!Config::Instance()->OverlayMenu.value_or_default()) { MenuOverlayBase::Present(); return; } // ... 设备类型识别、窗口句柄变更校验、渲染资源初始化逻辑 { ScopedSkipHeapCapture skipHeapCapture {}; // Render menu if (_dx12Device) RenderImGui_DX12(pSwapChain); } // ... 临时COM对象释放 }

这条路径不依赖超分管线是否运行,即使超分初始化失败,用户也能正常打开菜单调整设置。

2. Streamline 代理穿透

使用 NVIDIA Streamline 框架的游戏,会通过 COM 代理对象包装真实的 D3D12 设备和命令队列。如果直接拿代理对象初始化 ImGui,资源创建会全部失败。

代码中通过特定 IID 穿透代理层,拿到原生的 D3D12 对象,这是兼容大量 Streamline 系游戏的关键一步:

// 源码位置:menu_overlay_dx.cpp / 静态全局函数 CheckForRealObject / 穿透Streamline COM代理获取原生D3D12对象 static bool CheckForRealObject(std::string functionName, IUnknown* pObject, IUnknown** ppRealObject) { if (streamlineRiid.Data1 == 0) { auto iidResult = IIDFromString(L"{ADEC44E2-61F0-45C3-AD9F-1B37379284FF}", &streamlineRiid); if (iidResult != S_OK) return false; } auto qResult = pObject->QueryInterface(streamlineRiid, (void**) ppRealObject); if (qResult == S_OK && *ppRealObject != nullptr) { LOG_INFO("{} Streamline proxy found!", functionName); (*ppRealObject)->Release(); return true; } return false; }

3. 描述符堆隔离

ImGui 的字体纹理需要 SRV 描述符,我们单独创建了一个 64 项的 CBV_SRV_UAV 描述符堆。创建时用ScopedSkipHeapCapture跳过全局堆捕获逻辑,避免 ImGui 自己的描述符堆干扰游戏的根签名恢复机制:

// 源码位置:menu_dx12.cpp / Menu_Dx12 构造函数 / 独立SRV描述符堆创建,跳过全局堆捕获避免干扰游戏 D3D12_DESCRIPTOR_HEAP_DESC srvDesc = {}; srvDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; srvDesc.NumDescriptors = SRV_HEAP_SIZE; srvDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; { ScopedSkipHeapCapture skipHeapCapture {}; if (pDevice->CreateDescriptorHeap(&srvDesc, IID_PPV_ARGS(&_srvDescHeap)) != S_OK) return; } g_pd3dSrvDescHeapAlloc.Destroy(); g_pd3dSrvDescHeapAlloc.Create(pDevice, _srvDescHeap);

4. 严格的资源状态守护

插入绘制指令前后,必须做完整的资源状态转换,并且绘制完成后原样恢复,不能给游戏留下 “脏状态”。这是 DX12 渲染注入的铁则:你可以借命令列表画画,但用完必须把状态恢复成你接手时的样子:

// 源码位置:menu_dx12.cpp / Menu_Dx12::Render / 绘制前后资源状态转换与还原,保证游戏渲染状态不受影响 // 1. 转为渲染目标状态 D3D12_RESOURCE_BARRIER outBarrier = {}; outBarrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; outBarrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; outBarrier.Transition.pResource = outTexture; outBarrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; outBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_UNORDERED_ACCESS; outBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET; pCmdList->ResourceBarrier(1, &outBarrier); // 2. 绑定描述符堆、渲染目标,执行 ImGui 绘制 pCmdList->SetDescriptorHeaps(1, &_srvDescHeap); _device->CreateRenderTargetView(outTexture, &rtDesc, _renderTargetDescriptor[backbuf]); pCmdList->OMSetRenderTargets(1, &_renderTargetDescriptor[backbuf], FALSE, NULL); ImGui_ImplDX12_RenderDrawData(ImGui::GetDrawData(), pCmdList); // 3. 恢复为游戏原始状态 outBarrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET; outBarrier.Transition.StateAfter = D3D12_RESOURCE_STATE_UNORDERED_ACCESS; pCmdList->ResourceBarrier(1, &outBarrier);

5. 分辨率与窗口自适应

游戏切换全屏、窗口、修改分辨率时,交换链缓冲会重建。代码每帧都会校验渲染目标的尺寸、格式,发生变化时自动销毁旧资源并重建:

  • 窗口句柄(HWND)变更 → 销毁整个 ImGui 上下文,用新句柄重新初始化
  • 缓冲尺寸 / 格式变更 → 仅重建渲染目标纹理和 RTV,保留 ImGui 上下文

四、设置生效链路:从菜单点击到渲染更新

菜单里修改的配置不是 “点一下就立刻生效” 这么简单,根据参数类型不同,有三套生效机制,全部在渲染管线内完成,无需重启游戏。

1. 即时生效参数

锐度、亮度、调试视图开关这类每帧读取的参数,修改后直接写入 Config,下一帧渲染时就会读取最新值,无需任何重建操作:

// 源码位置:menu_common.cpp / MenuCommon::RenderActiveImageSettings / 锐度滑块参数即时写入配置,下一帧生效 float sharpness = config->Sharpness.value_or_default(); if (ImGui::SliderFloat("Sharpness", &sharpness, 0.0f, 1.0f)) config->Sharpness = sharpness;

2. 后端切换:三帧热切换

切换超分后端(FSR ↔ DLSS)是重量级操作,需要销毁旧的超分上下文并创建新的。为了避免 GPU 还在使用旧资源就释放导致 TDR 崩溃,采用三帧分步热切换:

  • 第 1 帧:标记销毁,保存旧创建参数,延迟释放 D3D12 资源
  • 第 2 帧:创建新的超分后端对象
  • 第 3 帧:初始化新后端,验证成功后正式接管渲染

整个过程对用户透明,只会感觉到一到两帧的轻微卡顿,无需退出游戏、无需重载场景。

3. 三态配置系统

所有配置项都基于CustomOptional<T>三态设计,区分默认值、用户持久化值、运行时临时值:

  • 默认态:INI 无配置,使用代码内置默认值
  • 用户态:用户在菜单修改并保存,写入 INI 持久化
  • 临时态:代码运行时动态覆盖,不写入 INI,重启后失效

这套设计让自动适配、临时兜底的配置不会污染用户的持久化设置。

五、踩过的核心坑点

  1. 描述符堆未绑定导致花屏:DX12 不会自动绑定描述符堆,绘制 ImGui 前必须手动调用SetDescriptorHeaps,否则字体、控件全是花块。
  2. 状态未还原导致设备移除:绘制完成后没把资源状态转回游戏原本的状态,后续游戏渲染用了错误状态的资源,直接触发驱动超时崩溃。
  3. Streamline 代理导致初始化失败:直接使用游戏传入的设备对象,在 Streamline 游戏里会全部失败,必须穿透代理层拿原生对象。
  4. 分辨率切换崩溃:游戏切换分辨率后旧缓冲已经被释放,还持有旧指针就会访问违规。每帧校验资源有效性,变更时同步重建。

写在最后

很多人提到游戏注入 UI,第一反应是 “外挂式悬浮窗”,但实际上成熟的游戏内工具,都会选择管线内联的 Overlay 方案。它没有额外窗口句柄、不会被窗口管理器拦截、和游戏画面严格同帧输出、也更难被反作弊误判。

这套菜单系统的设计核心就是:尽量少地干扰游戏,尽量多地复用管线。只在必要的节点插入最少的绘制指令,用完立刻恢复现场,这也是做渲染注入类工具的核心原则。

下一篇会拆解菜单的交互基础:输入拦截系统,如何实现菜单打开时接管键鼠,关闭时完全透明不影响游戏操作。

相关新闻

  • 呆啵宠物DyberPet:让二次元角色活在你的桌面,打造专属数字伙伴的终极指南
  • linux系统编程(一):pthread常用函数
  • 建筑石材选型的数据分析:用pandas对比8类石材性能

最新新闻

  • Playwright企业级测试架构:模块化分层与可扩展性设计
  • OpenClaw飞书AI副驾驶:Windows零基础部署与技能实战
  • Claude Code不是插件,是本地智能体运行时
  • Git源码泄露:原理、探测与防御全解析
  • Grok-3小说工业化实战:长文本连贯性与角色记忆的爆款生成逻辑
  • SVG图片钓鱼攻击:从XML到恶意代码的隐蔽攻击链剖析

日新闻

  • 终极指南:如何用shadPS4在电脑上免费畅玩PS4游戏
  • 打造个性化Instagram Clone:主题定制与用户体验优化技巧
  • 未来展望:RoseTTAFold-All-Atom的发展路线图与社区支持资源汇总

周新闻

  • Visual C++运行库修复终极指南:5分钟快速解决Windows软件启动错误
  • 手把手教你构建统计局地区经济数据爬虫:从环境搭建到数据持久化全指南
  • 2026多Agent深度解析:用AI团队替代单一模型,四种架构实战落地

月新闻

  • 【总结】入门篇:50句话让你记住架构核心概念
  • WeChatMsg技术方案解析:实现Mac微信数据自主管理的完整解决方案
  • WeChatMsg:革新性微信数据备份方案,打造你的专属数字记忆库

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号