一个面向"调画质"场景的 Windows 性能计数器。 除了 FPS / 占用率 / 温度 / 显存这些常规项,它还想回答两个市面工具大多答不了的问题: 「这次掉帧到底是 GPU 还是 CPU」、「光追和 DLSS 到底吃掉了多少时间」。
⚠️ 这是一个仍在早期、且部分指标尚未做成的项目,请先读 已知问题。RT Core / Tensor 两项主要卖点目前未成功。
┌──────────────────────────────────────────┐
│ NextPerf.exe(主程序) │
│ 传感器采集 · 注入器 · 桌面叠加 · 自绘界面 │
└───────────────┬──────────────────────────┘
│ 共享内存(三个块,见下)
┌───────────────┴──────────────────────────┐
│ Config(双向) Sensors(主→钩子) │
│ Telemetry(钩子→主,**每个进程一份**) │
└───────────────┬──────────────────────────┘
│ CreateRemoteThread + LoadLibraryW
┌───────────────┴──────────────────────────┐
│ NextPerfHook.dll(注入游戏进程) │
│ 挂钩 Present · 采集帧数据 · 在游戏里画面板 │
└──────────────────────────────────────────┘
- 不注入也能用:NVML / NVAPI / PDH / WMI 读硬件指标,在桌面叠加窗口上显示。
- 注入之后:挂钩 DXGI / D3D11 / D3D12 采集真实帧数据, 并把面板画进游戏画面(独占全屏下也能显示)。
- 32 位游戏会自动换成 32 位钩子(走 WoW64 注入)。
- 微软商店 / Xbox 应用(UWP)游戏也能注入(会往下找一层拿真实进程)。
这一版几乎没有新增用户可见的功能。它做的是让这个项目从"只能靠人试" 变成"能自己验证自己" —— 以及在这个过程中抓到并修掉了一批真 bug。
以前这个项目只有代码和零散笔记,改一处很容易忘掉另一处(本项目已经因此吃过 好几次亏:改了配置项忘了加存取导致开关重启就丢、加了界面控件忘了改窗口高度导致 按钮被挤出窗口)。现在有了:
| 文档 | 内容 |
|---|---|
docs/CATALOG.md |
总目录:架构、功能定位表、文件清单、10 条血泪教训 |
docs/HOOK.md + HOOK-FUNCTIONS.md |
钩子层全部功能与实现逻辑(含 63 条坑) |
docs/APP.md + APP-FUNCTIONS.md |
主程序层(注入器 / UI / 托盘 / 配置) |
docs/COMMON.md |
统计与 Low 帧算法、面板渲染 |
docs/DATA-STRUCTS.md |
共享内存逐字段数据契约(谁写、谁读、能不能持久化) |
docs/SENSORS.md + SENSORS-FUNCTIONS.md |
传感器与 ETW(含实证的坑) |
docs/LOGGING.md |
日志规范与自检约定 |
docs/SIMULATOR.md |
测试用模拟游戏说明 |
目录开篇第一条就是硬性规矩:每次改代码必须同步更新文档。 这不是形式主义 —— 理由写在里面了。
- 日志规范(
docs/LOGGING.md):两种日志各自的格式、位置、 以及"哪一端是最新行"(主程序在末尾、钩子在开头 —— 这是一个很容易搞错、 而且会直接导致看错日志的细节)。 - 机器可断言的自检:关键不变量现在会打成
SELFCHECK <名字> = OK|FAIL,测试脚本可以直接 grep:SELFCHECK unloadClean—— 卸载时所有钩子补丁必须全部还原 (有残留会让下次注入读到上一次的钩子 → 无限递归 → 栈溢出)SELFCHECK fpsLowLeAvg—— 1% Low 不可能高于平均帧率(定义如此)
- 崩溃上下文:钩子遇到异常时除了报出错地址,还会打出
inPresent / heapSwaps / frames / hookFlags等状态 —— 用来区分"崩在 Present 路径里"还是"崩在交换链重建窗口期"。
这一版最有价值的基建。 以前验证任何结论都要真人开游戏、复现、截图、贴日志; 而且像「改分辨率」「切窗口」这类测试会让用户的游戏闪退,在用户机器上反复试 是不负责任的。现在有两个模拟游戏进程:
| 产物 | 后端 | 能力 |
|---|---|---|
tests/sim/sim.exe |
DX11 + DX12 | 锁帧 / 垂直同步 / 分辨率切换 / 窗口状态切换 / CPU 与 GPU 负载注入 / 逐帧自报真实帧数据 |
tests/simvk/simvk.exe |
Vulkan | 同上(无 Vulkan SDK 环境下手写声明实现) |
它们自己是渲染方,所以自己知道每一帧的真实情况(帧间隔、CPU 耗时、 GPU 时间戳、Present 阻塞、交换链重建次数),并逐帧打成 JSON。 于是第一次可以问一个硬问题:钩子采到的数字,和真实数字对得上吗?
首次对照结果(DX11,60fps 垂直同步):
| 指标 | 模拟器(真值) | 钩子 | 偏差 |
|---|---|---|---|
| 帧间隔 ms | 16.670 | 16.735 | 0.4% |
| Present 阻塞 ms | 15.914 | 15.877 | -0.2% |
| CPU 帧时间 ms | 15.927 | 16.670 | 4.7%(口径差异) |
| GPU 帧时间 ms | 16.289 | 0.000 | -100% |
结论:钩子的核心帧数据是准的(帧间隔与 Present 阻塞偏差都在 1% 以内, 这两个正是帧时间 / Low 帧的基础量)。 GPU 帧时间读到 0 已结案:模拟器每帧 GPU 工作量只有约 0.005ms,低于测量分辨率, 读 0 是预期的。用户后来在真机上确认这个指标是对的(见下面「已知问题 · 指标不准」表)。 教训写进
CATALOG.md教训 14:用工作量远低于测量分辨率的负载去验证一个测量值,等于没验证。
新增的测试脚本:
tests/verify_framedata.py—— 上面那张对照表的生成器tests/sim_stress.py—— 复现「改分辨率 / 开关垂直同步 / 切窗口」这类 会触发交换链重建的操作,看崩不崩(支持--no-inject作对照组)tests/sim/sim_drive.py—— 模拟器的参考驱动 + 集成自测
这一版靠"通读代码 + 模拟器对照"抓到并修掉了这些:
| Bug | 性质 |
|---|---|
NPDefaultConfig() 越界写 16 字节 |
reserved[] 被逐步缩小到 [4],而清空循环还写 i < 8。后果是静默清掉紧邻的 sensors.magic/version → 发布的传感器块 magic=0 被钩子忽略。已改成跟着 sizeof 走 |
| 自动学习时忘了加锁 | games.push_back 会让守护线程的迭代器失效 → 随机崩溃。界面那条路径有锁,学习这条漏了 |
| 游戏列表仍用跨进程时钟判活 | 正是那个会算出「42 亿毫秒」的判据。已改为读主循环算好的 telemetryLive |
| AI 引擎两路 100% 失效 | PDH 实例名大小写错了(engtype_compute vs 真实 engtype_Compute),而 wcsstr 大小写敏感 → 命中 0 个 → 永远显示 -1.0% |
崩溃上下文里 inPresent 恒为 0 |
变量只声明从没赋值,比不报还糟(会误导定位) |
早期怀疑是「没有挂钩 ResizeBuffers」导致后台缓冲引用没释放。
后续在可复现靶子上证伪了:崩溃发生在第一次交换链重建之前,与 ResizeBuffers 无关。
完整的调查与排除清单见下面的「已知问题 · 闪退」一节。
📋 **完整清单(含"哪些数据读不到、需要什么条件、哪些是死代码、哪些口径还没统一")在
docs/WHY-VSYNC.md—— 给玩家看的科普:为什么"锁帧 + 垂直同步"决定流畅度 (实测:平均 105fps 反而比 稳 60fps 更卡),以及怎么用面板的几个数判断自己顺不顺。
docs/TODO.md** —— 那份是状态唯一权威来源,本节只保留结论与细节。 两处说法不一致时,以docs/TODO.md为准,并把两处都改掉。
| 指标 | 状态 | 说明 |
|---|---|---|
| GPU 帧时间 | ✅ 可用 | 用户实测确认正确。 (曾一度怀疑它有问题:模拟器对照读到 0 —— 那是模拟器负载太低造成的, 它每帧 GPU 工作量只有约 0.005ms,低于测量分辨率,读 0 是预期的。) |
| 帧时间 | ✅ 可用 | = 相邻两次 Present 的间隔(msBetweenPresents)。每一次真实 Present 都记账,不做帧内合并(合并曾两次被判定为回归并回退)。模拟器对照:平均值偏差 0%、P50 偏差 3% |
| CPU 帧时间 | ✅ 可用 | 显示 cpuBusy(= 上一帧 Present 返回 → 本帧 Present 开始,即 CPU 真正干活的时间)。不含排队时间 —— 这一行的用途是判断 CPU 是不是瓶颈,而排队(垂直同步等待)不是 CPU 在干活。 |
| 1% / 0.1% Low | ✅ 可用 | 照 Intel PresentMon 的口径:在滑动窗口内直接取百分位(1% Low = 窗口内帧时间的 P99 取倒数;0.1% Low 窗口 3 秒)。 为什么不用严格百分位:真实游戏会把一帧拆成多次 Present(实测 8.4/24.9ms 交替、平均恰好 16.65), 严格百分位会落在 24.9ms 那一半上 → 显示 40 多,与体感不符。 显示时向下取整(59.6 → 59,避免和「满帧 60」混淆) |
| RT Core 负载 | ❌ 未成功 | Windows 上没有公开途径读到硬件利用率 |
| Tensor / AI 负载 | ❌ 未成功 | 同上。PDH 那两路的大小写 bug 已修,但尚未验证到非 -1.0 的读数 |
现象:D3D12 游戏可能闪退,崩溃点固定在 NVIDIA 用户态驱动的一个偏移上
(nvwgf2umx.dll+0x330124)。
⚠️ 2026-10-11 新证据:它不只发生在"反复切换设置"时。 用户实测:自动注入进 re8 后 正常游玩约 8 秒就崩(frames=466、heapSwaps=0、没改过分辨率),签名与之前完全一致:SEH: code=0xc0000005 at nvwgf2umx.dll+0x330124 SEH ctx: api=12 inPresent=1 presentHooked=1 frames=466 dev11=0 dev12=1 heapSwaps=0
inPresent=1,而我们的 VEH 在调用原始 Present 期间是故意解除武装的 (gJmpArmed=false,见PresentCommon)——所以这个访问违例发生在驱动内部,我们抓不到。 ✅ 根因已定位并修复(v1.6.4):历史二分显示崩溃需要「绑定我们的 RTV」与「上传那一半」 同时存在,而把CopyTextureRegion去掉仍然崩 —— 于是上传那一半里剩下的唯一每帧动作 就是每帧新建一个约 300KB 的 UPLOAD 资源。现已改成常驻上传缓冲环(4 个,按 fence 复用, 见np_draw.h),把这个动作从每帧路径上删掉,顺带省掉约 18MB/s 的分配抖动。用户实测确认:连续 2755 帧 / 60fps ≈ 53 秒,期间还含一次交换链重建(
heapSwaps=1), 零SEH、零闪烁;而修复前同一场景 466 帧(约 8 秒)就崩。
⚠️ 观测窗口是一次约 1 分钟的正常游玩,不等于长期证伪。若将来在别处复现,继续往下走: 装 Windows 的「图形工具」可选功能 (DISM /Online /Add-Capability /CapabilityName:Tools.Graphics.DirectX~~~~0.0.1.0,需要管理员), D3D12 验证层会把"哪一次调用用错了"直接写进NextPerfHook.log(转储代码早已在钩子里)。 注意:验证层必须在游戏创建设备之前启用才对该设备有效 → 诊断时要尽早注入 (与现在的 10 秒延迟冲突),需要单独一轮诊断构建。
已经查到的(全部有工作区内的可复现实验,见 tests/_bisect.py):
- 闪退可以在工作区内复现:
python tests\sim_stress.py --api=dx12,约 250 帧必崩, 崩溃偏移与真游戏日志一致 —— 不需要真人开游戏就能验证。 - 与
ResizeBuffers无关。早期的怀疑(没挂钩它 -> 引用没释放 -> 重建失败)被证伪: 在可复现的靶子上,崩溃发生在第一次交换链重建之前,而且heapSwaps=0。 - 不是设备不一致、设备被移除、RTV 创建失败、描述符堆大小、槽位用尽。
- 触发条件是「绑定我们自己的 RTV」与「上传那时的那一半操作」同时存在 ——
但这两半单独都不崩,也排除了 draw、各条资源屏障、视口、
CopyTextureRegion、描述符堆绑定、PSO / 根签名 / 根描述符表。根因尚未定位。
为什么当时没修(历史记录):
- 唯一试出来的规避办法是关掉 D3D12 的游戏内叠加 —— 但那等于砍掉核心功能, 不能在 DX12 游戏里叠加,这个工具就没有意义,所以没有这么做。
- 曾以为触发条件是「反复改分辨率 / 反复开关垂直同步 / 反复切窗口全屏」,于是判断
「很少有玩家会反复做这些操作,正常玩不受影响」。
⚠️ 这个判断是错的:2026-10-11 用户实测正常游玩约 8 秒就崩 (frames=466、heapSwaps=0、全程没改过设置)。这条错误假设已随手上的修复一并作废。 - 要真正定位,需要的下一步是装上 D3D12 的「图形工具」让验证层直接报错
(本机没装,
D3D12GetDebugInterface不可用)。验证层的接入代码已经写好, 装上之后崩溃原因会直接出现在NextPerfHook.log里。
临时的自我保护(已经实现并实测有效):
- 切换窗口 / 无边框 / 全屏时,若判定交换链刚被重建过, 会静默若干帧不往游戏的命令列表里录命令,避开驱动那个崩溃点。
⚠️ 这个静默机制的触发条件是「RTV 槽位攒满 8 个」,而槽位是按后台缓冲对象逐个累加的 —— 每次交换链重建都会换掉一整批后台缓冲对象、各占一个新槽,所以它会触发, 但要等到第二~三次重建之后:第一次重建完全没有保护(而崩溃恰恰发生在重建那段窗口期)。 准确说法是「来得太晚」,不是「从未生效」。 已在docs/HOOK.md与docs/COMMON.md记过;要彻底修好应当补上ResizeBuffers钩子,由它显式触发静默,而不是靠副作用猜。
如果你遇到了闪退:先确认用的是 v1.6.4 或更新(更早的版本请更新)。
若在 v1.6.4 之后仍然复现,请先复制一份 %TEMP%\NextPerfHook.log 再报告 ——
下一步是装 D3D12 验证层(DISM /Online /Add-Capability /CapabilityName:Tools.Graphics.DirectX~~~~0.0.1.0,需管理员),
它会把「哪一次调用用错了」直接写进日志。注意验证层必须在游戏创建设备之前启用,
所以要单独做一轮「尽早注入」的诊断构建(与现在的 10 秒延迟冲突)。
仍未做的根治项:给 ResizeBuffers 加钩子。现在只能靠「RTV 槽位攒满」这个副作用
间接感知交换链重建(见上),这也是「全屏后换场景闪烁」类问题迟迟不能根治的原因。
现象:钩子开着 → 点叉关游戏 → 窗口消失,但后台进程没被清理;同时 NextPerf 的 状态栏在「钩上 / 没钩上」之间每 2.5 秒跳一次。
✅ 结论(2026-10-11 用户 A/B 实测):"进程不退"是游戏自己的问题,不是我们的。 完全不启动 NextPerf、正常玩一次并关窗,进程同样退不干净。 钩子侧证据一致:看门狗证明渲染线程没有卡在我们的 Present 路径里,而且日志里完全没有
DETACH: enter(进程是被任务管理器结束的,TerminateProcess不走DllMain)。 下面那条"退出时反复注入"是另一件事、确实是我们自己的 bug,已修 —— 两件事不要混。
- 状态栏跳变这一半已定位、已修(在 main,未发布):判定"钩子已死"后,主程序把上一次
的帧数记成了 0;下一次读到的冻结帧数与 0 不相等,就被当成"又长了"→ 复活 → 2.5 秒
后再判死,无限翻。现在只清计时器、保留冻结帧数,改法见
CHANGELOG.md。 - 进程不退:已经抓到一条确凿的、属于我们自己的原因,并做了根因修复(在 main,未发布)。
NextPerf 退出时看护线程还在往游戏里反复注入 —— 实测日志(你点「退出」之后):
每次注入的钩子一启动就看到
21:07:30.150 quit from UI button 21:07:30.794 inject pid 20124 (re8.exe): remote LoadLibrary returned 0xE2F50000 21:07:31.618 inject pid 20124 (re8.exe): remote LoadLibrary returned 0xE2F50000cfg.quit=1→ 立刻自卸载 → 注入标记互斥量消失 → 看护线程 800ms 后又注入,0.8 秒一轮。往一个正在关停的进程里反复LoadLibrary, 撞上 loader lock 就是永久挂死 —— 正是"窗口没了、进程退不掉"。 修法:先停看护线程、再广播退出(CleanupAndExit换了顺序),并加两道注入闸门 (退出中不注入 / 正在请求卸载的 pid 不注入)。 - 看门狗当场给出了答案(21:16 那次复现):
而且没有任何"PresentCommon 进入后未返回"的行 → 我们的 Present 路径被排除。 随后的 A/B(不启动 NextPerf 也一样退不干净)确认这是游戏自身的问题 —— 到此结案。
watchdog: 已有 5.2s 没有 Present 调用 —— 游戏侧停住了,不是卡在我们的 Present 路径里
下次复现怎么定位(这次钩子日志回绕把现场冲掉了一半,只能靠推断):
- 复现后立刻把
%TEMP%\NextPerfHook.log复制一份(回绕随时会发生;上限已从 512 KB 提到 4 MB,读法见docs/LOGGING.md第 1 节); - 搜
watchdog:—— 这一行会直接给结论:watchdog: PresentCommon 进入后 X.Xs 未返回 (stage=…)→ 卡在我们这条路径里,stage还会告诉你卡在哪一段(0=帧统计段/1=叠加绘制段/2=原始 Present(驱动)/3=收尾段);watchdog: 已有 X.Xs 没有 Present 调用→ 游戏侧停住了,不是卡在我们的 Present 路径里 (别再动钩子的 Present 代码,去查游戏的关停)。
- 顺手搜
DETACH: enter(判断 DLL 卸载有没有走到、卡没卡在RestoreAllHooks)。
现象:让 NextPerf 自动盯 re8 → 游戏启动即闪退;但进到游戏里再手动注入不闪退。
判断:自动注入是"进程一出现就注入",那时游戏自己正在初始化 D3D 设备/交换链,
而我们的钩子在那时还要额外建一个 D3D11 设备 + 隐藏窗口去探测交换链 vtable
(ProbeSwapChainVtable)—— 两边一起动 driver,代价就是闪退。
暂时这样处理(在 main,未发布):探测到进程后先等 10 秒再自动注入
(injector.cpp 的 kAutoInjectDelayMs,日志里会打 auto-inject delay: 等 pid=… …)。
手动注入不受影响 —— 那本来就是"游戏已经在跑"的场景。
⚠️ 这只是时序缓解,不是根因修复:根因是"为了拿 vtable 必须自建 D3D 设备"这件事在 游戏初始化期不安全。要根治得换探测方式,属于下一轮的事。
dist\NextPerf.exe
- 打开后默认开始监控,右上角出现半透明面板。
- 想看在游戏里的数据(帧率、帧延迟,以及独占全屏下的显示),三条路:
- 游戏已经在跑:切到游戏窗口,再切回 NextPerf,点 注入到上一个游戏;
- 想让它自动盯:添加游戏 exe → 选中游戏主程序,之后这个进程一启动
就会自动注入 ——
⚠️ 但要等它启动约 10 秒之后才动手(早注入会让游戏闪退, 见下面「已知问题」)。商店游戏请用「注入到上一个游戏」 —— 它们的 exe 在C:\Program Files\WindowsApps\下,普通用户读不了; - 命令行:
NextPerf.exe --inject <pid>(退出码 0=成功 / 1=参数错 / 3=失败)。
- 想改显示什么,勾左边的复选框,右上角面板和中间的预览会立刻跟着变。
- 关掉主窗口不会退出,程序缩到系统托盘;托盘右键可以开始/停止/退出。
自检(不开窗口,跑一遍传感器和探测,结果写 selftest.txt):
dist\NextPerf.exe --selftest
# 构建(需要 zig 0.16.0;本项目不用 MSVC / MinGW)
cd NextPerf
cmd /c build.bat
# 构建测试宿主
cmd /c tests\build_hosts.bat
cmd /c tests\sim\build_sim.bat # 模拟游戏(DX11/DX12)
cmd /c tests\simvk\build_simvk.bat # 模拟游戏(Vulkan)
# 回归
python tests\run_all.py --skip-build # SUITE=0 为通过
python tests\struct_check.py # 校验共享内存结构体镜像一致
python tests\sim\sim_drive.py # 模拟器自带的集成自测
python tests\verify_framedata.py # 用模拟器的真值校验钩子帧数据dist\NextPerf.exe 没被占用(你可能正开着 NextPerf)。
不要为了解开占用去杀用户的进程。
src/app/ 主程序:主循环、注入器、界面、托盘、配置
src/hook/ 注入到游戏里的钩子:探测、挂钩、采集、画面板、卸载
src/common/ 两层共用的数据契约与算法(**改结构体要动三处**)
src/sensors/ 传感器:NVML / NVAPI / ADL / HWiNFO / PDH / WMI
src/etw/ ETW(DxgKrnl Present 事件)
tests/ 测试脚本 + 两个模拟游戏
docs/ **代码文档(改代码请同步更新)**
开始阅读代码前,请先看 docs/CATALOG.md ——
它是总目录,包含功能定位表、文件清单和一份"血泪教训"清单(
例如"绝不能在 Present 处理函数中途 return"、"不要跨进程比较时钟")。
| 项 | 值 |
|---|---|
| 操作系统 | Windows 11 家庭版中文版 · 26H2 · Build 26300.9457 |
| CPU | Intel Core Ultra 9 275HX(24 逻辑处理器) |
| 内存 | 32 GB |
| 显示适配器 | 混合显卡:Intel 核显 + NVIDIA RTX 5080 Laptop(16 GB) |
| 显卡驱动 | 617.42 —— Studio 驱动 |
| 显示 | 内建单屏 3840×2400 @ 60Hz |
| 编译器 | zig 0.16.0(zig c++ -target x86_64-windows-gnu / x86-windows-gnu),无 MSVC / MinGW / Vulkan SDK |
实测游戏:《生化危机8》(微软商店版,DX12)、《漫威蜘蛛侠》(DX12)。
- Intel PresentMon ——
CPU Busy / CPU Wait 的拆分公式来自它(MetricsCalculatorCpuGpu.cpp):
msCPUBusy = 上一帧 Present 返回 → 本帧 Present 开始;msCPUWait = 本帧在 Present 内部停留的时长。
本项目的「CPU 帧时间」取其中的 Busy(判断 CPU 瓶颈用,不含排队)。
⚠️ Low 帧没有采用它的严格百分位口径 —— 真实游戏会把一帧拆成多次 Present, 严格百分位会落在假的短帧/长帧上;本项目用**窗口平均(1000ms)**更贴近体感。 CPU Busy / Wait 的拆分公式也来自它。 - NVAPI / NVML —— 显存、频率、利用率;接口 ID 与结构体定义以
PresentMon 里的
nvapi_interface_table.h/nvapi.h为准(那是权威列表)。
本项目为个人学习与自用工具,按现状提供,不保证任何指标准确。 注入行为可能被反作弊判定为异常,请自行评估风险; 本项目不会、也不打算尝试绕过任何反作弊机制。