很多资料里把游戏内注入的 ImGui UI 称作 “悬浮菜单”容易让人误解成类似桌面悬浮窗的独立顶层窗口。实际上 upscalerBridge 这套菜单没有创建任何额外的系统窗口而是通过 Hook 游戏渲染管线把 UI 绘制指令直接插入到原生渲染流中和游戏场景在同一个 GPU 渲染流程里混合输出。它本质是渲染管线内部的 Overlay 覆盖层而非操作系统级的悬浮界面。本文基于 DX12 路径完整拆解这套菜单的架构设计、渲染接入点、资源管理和设置生效链路。一、整体架构双渲染路径全管线内联整套菜单系统分成三层架构所有绘制都发生在游戏渲染进程内部无外部窗口、无跨进程绘制通用逻辑层MenuCommon和渲染 API 完全解耦负责控件布局、主题样式、配置读写、输入处理、状态管理渲染适配层Menu_Dx12 / MenuOverlayDxDX12 资源创建、渲染目标管理、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::vectorUpscaler 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. 三态配置系统所有配置项都基于CustomOptionalT三态设计区分默认值、用户持久化值、运行时临时值默认态INI 无配置使用代码内置默认值用户态用户在菜单修改并保存写入 INI 持久化临时态代码运行时动态覆盖不写入 INI重启后失效这套设计让自动适配、临时兜底的配置不会污染用户的持久化设置。五、踩过的核心坑点描述符堆未绑定导致花屏DX12 不会自动绑定描述符堆绘制 ImGui 前必须手动调用SetDescriptorHeaps否则字体、控件全是花块。状态未还原导致设备移除绘制完成后没把资源状态转回游戏原本的状态后续游戏渲染用了错误状态的资源直接触发驱动超时崩溃。Streamline 代理导致初始化失败直接使用游戏传入的设备对象在 Streamline 游戏里会全部失败必须穿透代理层拿原生对象。分辨率切换崩溃游戏切换分辨率后旧缓冲已经被释放还持有旧指针就会访问违规。每帧校验资源有效性变更时同步重建。写在最后很多人提到游戏注入 UI第一反应是 “外挂式悬浮窗”但实际上成熟的游戏内工具都会选择管线内联的 Overlay 方案。它没有额外窗口句柄、不会被窗口管理器拦截、和游戏画面严格同帧输出、也更难被反作弊误判。这套菜单系统的设计核心就是尽量少地干扰游戏尽量多地复用管线。只在必要的节点插入最少的绘制指令用完立刻恢复现场这也是做渲染注入类工具的核心原则。下一篇会拆解菜单的交互基础输入拦截系统如何实现菜单打开时接管键鼠关闭时完全透明不影响游戏操作。