3
0

一款零延迟的游戏滤镜工具

2026-10-15
2026-10-15

某款工具很多滤镜需要vip,所以做了一款平替工具。目前处于早期开发阶段,欢迎试用和反馈。QQ群:1085994601


下载地址:https://pan.quark.cn/s/2c29bb07d4a4

第一部分:GameLens 能做什么

GameLens 是一个 Windows 桌面程序(WPF / .NET 10),免费,由三个模块组成。

游戏内监控

悬浮层显示 FPS、帧时间、1% Low 和 0.1% Low、实时帧时间曲线,以及 CPU / GPU 的占用、温度、频率、功耗,还有显存、内存和本次游戏时长。

布局有两种:一种是常见的竖向列表;另一种是贴在屏幕顶部的横条,类似 FPS 103 | CPU 72% 59℃ 1900MHz | GPU 78% 67℃,不同类型的数值用不同颜色区分,扫一眼就能看到关键信息。悬浮层还分“极简 / 标准 / 详细 / 自定义”四档样式,用快捷键随时切换。

游戏滤镜

可调亮度、对比度、饱和度、色温、Gamma 和暗部提亮,另外还有黑白、复古、夜视几种风格模式,以及针对红色弱、绿色弱、蓝色弱的色弱辅助模式。内置了一组预设,比如“竞技·暗部提亮”(拉高 Gamma 和暗部,适合在暗处找人)、“电影感”、“暖色护眼”,也可以自己调好参数存成新预设。

性能统计

每次游戏会自动记录成一条会话,事后可以回看。想做严肃对比时,按快捷键开始跑分、再按一次结束,GameLens 会生成一份跑分报告:帧时间曲线(卡顿帧会被标出来)、FPS 分布、百分位图。最多可以把 6 次跑分放在一起对比,报告可以导出成 CSV 和 PNG。如果你之前用 PresentMon 或 CapFrameX 录过数据,也能直接导入进来一起比。

怎么用

运行 GameLens.exe,同意 UAC 提权(读取帧数据和硬件传感器需要管理员权限),然后正常打开游戏就行。GameLens 会自动识别当前前台的游戏,不需要手动选进程。关掉主窗口时程序会缩到托盘。

默认快捷键如下,都可以在设置里改:

快捷键

功能

Ctrl+Shift+F7

显示 / 隐藏悬浮层

Ctrl+Shift+F8

切换悬浮层样式

Ctrl+Shift+F9

开关滤镜

Ctrl+Shift+F10

切换滤镜预设

Ctrl+Shift+F11

开始 / 结束跑分

每个游戏都可以单独指定悬浮层样式和滤镜预设。比如射击游戏默认开“竞技·暗部提亮”,剧情游戏用“电影感”,切游戏时自动跟着换。


第二部分:不注入是怎么做到的

先给一张总览表,后面逐项展开:

功能

实现方式

帧数据

系统 ETW 事件(DXGI / D3D9 / DxgKrnl 的 Present),和微软 PresentMon 同源

悬浮层

独立的透明置顶窗口,鼠标穿透

颜色滤镜

Windows 放大镜 API 的全屏颜色矩阵 MagSetFullscreenColorEffect

Gamma / 暗部提亮

显卡 Gamma 曲线 SetDeviceGammaRamp

硬件数据

Windows 性能计数器 + LibreHardwareMonitor

核心思路是:游戏进程里发生的事,Windows 本身已经知道很多;只要找对系统提供的观察窗口和显示接口,就不需要钻进游戏内部。

1. 帧数据:从 ETW 里“听” Present

每当游戏提交一帧,图形栈都会产生 ETW(Event Tracing for Windows)事件。微软自己的帧分析工具 PresentMon 就是靠这个原理工作的。GameLens 开一个实时 ETW 会话,订阅三个提供程序里各一个事件:

private static readonly Guid DxgiProvider    = new("CA11C036-0102-4A2D-A6AD-F03CFED5D3C9");
private static readonly Guid D3D9Provider    = new("783ACA0A-790E-4D7F-8451-AA850511C6B9");
private static readonly Guid DxgKrnlProvider = new("802EC45A-1E99-4B83-9920-87C98277BA9D");
​
private const int DxgiPresentStart   = 42;
private const int D3D9PresentStart   = 1;
private const int DxgKrnlPresentInfo = 184;

DXGI 覆盖 D3D10/11/12 游戏,D3D9 覆盖老游戏,DxgKrnl 是内核层事件,用来兜底 Vulkan 和 OpenGL 这类不走 DXGI 的程序。同一个进程可能同时触发多个来源的事件,所以每个进程按来源分别记录,取数时按 DXGI → D3D9 → 内核的优先级挑一个当前活跃的来源。API 层的事件更贴近游戏实际的提交节奏,内核事件只在前两者都没有时才用。

两次 Present 的时间差就是帧时间。每个来源用一个容量 16384 的环形缓冲区保存“时间戳 + 帧时间”,悬浮层要的平均 FPS、1% Low、曲线数据都从最近一段窗口里算出来。

有两个细节值得一提。

第一个是延迟。ETW 实时会话默认大约每秒才把缓冲区交给消费者一次,直接用的话悬浮层上的数字会一顿一顿地跳。解决办法是开一个 100ms 的定时器主动 Flush() 会话。

第二个是时钟对齐。ETW 事件带的是自己的相对时间戳,而“现在”是程序自己的 Stopwatch。两者之间有一个未知的偏移,而且事件送达有延迟。GameLens 每收到一个事件就算一次 事件时间戳 − 本地时钟,只保留其中最大的那个值。因为送达延迟只会让这个差值变小,所以最大值对应的就是延迟最小的那次,用它当偏移量最接近真实。有了统一的“现在”,才能准确判断一个进程是否还在出帧(超过 1 秒没有新帧就视为不活跃)。

另外,两帧间隔超过 1 秒的(比如暂停、切出游戏)不计入帧时间,免得一次切屏把 0.1% Low 拉成个位数。

2. 自动识别前台游戏

只有帧数据还不够,系统里出帧的进程很多:浏览器、视频播放器、启动器都在出帧。GameLens 每 500ms 判断一次,同时满足以下条件的才算“前台游戏”:前台窗口所属的进程正在出帧;不在排除列表里(内置了 Chrome、Edge、VLC、Steam 的网页组件、微信、QQ 等,用户还可以自己加);并且窗口是全屏或无边框全屏,或者用户已经在“游戏配置”里手动添加过这个程序。

游戏名称优先读 exe 文件版本信息里的产品名,读不到再退回到文件名,所以列表里显示的是“Delta Force”而不是一串进程名。

3. 悬浮层:一个“不存在”的窗口

不能在游戏画面里画,那就在游戏上面盖一个窗口。悬浮层是一个透明的置顶窗口,加了这几个扩展样式:

ex |= WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE;

WS_EX_TRANSPARENT 加 WS_EX_LAYERED 让鼠标点击直接穿透到下面的游戏;WS_EX_NOACTIVATE 保证它永远不会抢焦点,不会把游戏切到后台;WS_EX_TOOLWINDOW 让它不出现在任务栏和 Alt+Tab 里。

还有一个可选项:通过 SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) 让悬浮层不被截图和录屏捕获。自己看得见数据,直播和录像里却是干净的画面。

4. 滤镜:拆成“线性”和“非线性”两半

这是整个项目里最有意思的部分。

Windows 放大镜 API 有一个很少人知道的函数 MagSetFullscreenColorEffect,它接受一个 5×5 的颜色矩阵,由 DWM 在合成整个桌面时应用。因为是合成阶段顺带做的,不会增加额外的帧延迟。亮度、对比度、饱和度、色温、黑白、复古、夜视、色弱辅助,这些全都是 RGB 的线性变换,可以用矩阵相乘串起来:

m = m.Then(ColorMatrix.Saturation(p.Saturation))
     .Then(ColorMatrix.Temperature(p.Temperature))
     .Then(ColorMatrix.Contrast(p.Contrast))
     .Then(ColorMatrix.Brightness(p.Brightness));

但 Gamma 和暗部提亮是非线性曲线,矩阵表达不了。这部分交给显卡的 Gamma 曲线(SetDeviceGammaRamp),也就是显卡输出端的查找表。暗部提亮的曲线是这样设计的:在 Gamma 结果之上,只对 0.5 以下的亮度做抬升,越暗抬得越多,到中间调衰减为零,这样暗处能看清,亮处不发白。

var y = Math.Pow(x, 1.0 / Math.Max(0.1, p.Gamma));
if (p.ShadowLift > 0)
{
    var w = Math.Max(0, 1 - y / 0.5);
    y += p.ShadowLift * 0.25 * w * w * Math.Sqrt(y);
}

拆成两半还有一个意外的好处:它能处理独占全屏。独占全屏时 DWM 不参与合成,颜色矩阵直接失效。不过亮度、对比度、色温这几项其实是“每个通道各算各的”,可以折算进 R/G/B 三条独立的 Gamma 曲线里。所以 GameLens 检测到独占全屏时,会把这部分从矩阵挪到 Gamma 表;只有饱和度和风格模式这种需要通道之间混合的效果没法搬,会在滤镜页上明确提示“当前不生效”和原因。

Gamma 表这条路坑不少,下面三个都是实际遇到的。

驱动会拒绝太激进的曲线。 Windows 和部分显卡驱动会拒绝偏离线性太多的 Gamma 表。GameLens 的做法是按 100%、75%、50%、30% 的强度依次重试,用第一个被接受的,并把实际强度记下来。

游戏会覆盖 Gamma 表。 很多游戏在启动或切换分辨率时会重设 Gamma,滤镜就悄悄没了。所以 GameLens 会定期读回当前的 Gamma 表,和自己设置的比对,不一致就重新应用。

崩溃后屏幕颜色不会自己恢复。 放大镜的颜色矩阵在进程退出时由系统自动撤销,但 Gamma 表不会。如果程序被强制结束,屏幕会一直保持滤镜后的颜色。解决办法是一个简单的“脏标记”:应用 Gamma 时在数据目录写一个 gamma.dirty 文件,正常还原时删掉。下次启动发现这个文件还在,就说明上次没还原干净,先把 Gamma 重置为线性。

5. 统计:1% Low 和卡顿是怎么算的

1% Low 的定义各家工具不完全一样。GameLens 用的是“帧时间的第 99 百分位换算成 FPS”,0.1% Low 对应第 99.9 百分位。这个定义的含义很直观:最慢的那 1% 帧,至少有这么快。

卡顿判定采用相对阈值:某一帧的帧时间超过前 30 帧平均值的 2 倍,并且至少多出 8ms,才算一次卡顿。单纯用 2 倍判断的话,在 300 FPS 下 3ms 跳到 7ms 也会被算进去,但人眼根本感觉不到,所以加了 8ms 的绝对下限。

导入外部数据时,GameLens 会按列名找帧时间,FrameTimeMs、MsBetweenPresents、MsBetweenDisplayChange 都认,因此 PresentMon 和 CapFrameX 的 CSV 可以直接拖进来,和 GameLens 自己的跑分一起对比。

6. 一个和游戏无关、但很要命的坑

有位用户(其实就是我自己)在核显 + RTX 3060 的电脑上玩《三角洲行动》时,GameLens 隔三差五崩溃,日志里是 WPF 渲染线程的 UCEERR_RENDERTHREADFAILURE。原因是游戏全屏、切换显卡输出时,WPF 的硬件渲染设备丢失,而这种错误在进程内恢复不了。

最终的处理分两步:一是把 GameLens 自己的界面改为软件渲染(RenderOptions.ProcessRenderMode = SoftwareOnly)。GameLens 的界面很简单,CPU 画完全够用,换来的是不再和游戏抢 GPU 设备。二是在全局异常处理里识别 0x8898xxxx 这一类 milcore 错误码,遇到时自动重启自己,而不是直接退出。


已知限制

不注入的代价是有些事情做不到,这里如实列出。

独占全屏下看不到悬浮层(因为 DWM 不合成,覆盖窗口盖不上去),颜色矩阵也不生效,只剩 Gamma 表能处理的那部分。想要完整效果,请把游戏设成“无边框窗口”或“窗口化全屏”;好在现在大多数游戏默认就是这样,Windows 的全屏优化也让两者的性能差异很小。

开启 Windows HDR 后,系统会忽略 Gamma 表,Gamma 和暗部提亮不起作用。

滤镜作用于整个屏幕,悬浮层和其他窗口会一起变色。这是系统级颜色效果的天然属性。

CPU 温度和功耗需要安装 PawnIO 驱动,LibreHardwareMonitor 通过它读取 CPU 传感器。不装也不影响其他功能,只是这两项显示为空。

OpenGL / Vulkan 游戏的帧数据来自内核事件,个别情况下精度不如 DX 游戏。


写在最后

做完回头看,“不注入”这个约束其实逼出了一些更好的设计:滤镜是 DWM 合成时顺带完成的,零额外延迟;帧数据和 PresentMon 同源,结果可以直接互相对照;悬浮层就是个普通窗口,游戏更新再多次也不会把它弄坏。

GameLens 目前是 0.1.0 版本,基于 .NET 10,源码目录:

src/GameLens/
  Core/        设置、调度中心、快捷键、开机自启、日志、更新提醒
  Monitoring/  ETW 帧监控、硬件传感器、游戏识别
  Overlay/     悬浮层窗口
  Filters/     颜色矩阵、预设、滤镜引擎
  Stats/       会话记录、跑分录制、帧时间分析
  UI/          主界面、跑分报告、图表控件、托盘、主题

装好 .NET 10 SDK 后运行仓库根目录的 build.cmd 就能构建。欢迎试用,也欢迎反馈你在哪款游戏上遇到了问题。

评论