☰
逆向工程师的读心术:从着色器常量定位抗锯齿与帧生成挂载点
2026/10/18 15:07:53 网站建设 项目流程

逆向工程师的读心术:从着色器常量定位抗锯齿与帧生成挂载点

【免费下载链接】uncharted_pcUncharted: The Nathan Drake Collection running natively on Windows - by SuperMedo项目地址: https://gitcode.com/gh_mirrors/un/uncharted_pc

拿到一份没有源码的 PS4 游戏二进制,想给它塞进现代画质技术——时序抗锯齿(TAA/FSR/DLSS)、帧生成(FSR 3.1 FG / DLSS FG)——第一道坎不是"算法难",而是"往哪儿插"。游戏是自己的渲染循环,后处理、色调映射、UI 一层层叠在场景颜色之上;插早了,HUD 和特效会进历史缓冲;插晚了,拿不到线性 HDR 场景颜色与深度。本文以uncharted_pc(《神秘海域:德雷克合集》Windows 原生移植,由 SuperMedo 发起)为例,拆解它如何仅靠逐帧解析游戏着色器绑定的常量缓冲,在没有源码的情况下"读心"出三个关键挂载点:相机速度通道、场景颜色之后的第一个计算通道、以及 UI 绘制前的显示缓冲写入通道。

源码不可得的困境:挂载点为什么难找

PS4 游戏的 eboot 是 x86-64 二进制,图形指令走 GNM 命令流,着色器以二进制 IR 形式被打包。移植项目uncharted_pc的路径是:让原版可执行文件直接跑在 Windows 上,用 shadPS4 的视频核心与着色器重编译器把 GNM 翻译成 Vulkan(详见 ROADMAP.md)。这一步只解决了"画得出来",没解决"在哪里动手脚"。

时序类画质技术的共同输入需求是苛刻的:

  • 场景颜色必须是线性 HDR、且在游戏后处理(bloom、色彩分级)之前拿到,否则颜色重建无从谈起;
  • 深度必须是对应当前帧相机的那张场景深度;
  • 运动向量要表达"上一帧到这一帧"的像素位移,且必须排除 jitter 抖动本身的贡献;
  • UI 分离:插帧与历史累积都不能把 HUD 算进去,否则菜单会拖影、小地图会"糊"。

游戏内显然没有这些语义标记。你能看到的只有:一条 draw 命令、一个绑定的常量缓冲、一组目标和一组着色器哈希。所以问题转化为:如何在成百上千个 draw/dispatch 中,认出"这就是场景颜色之后的第一个计算通道"?

逐帧解析常量缓冲:签名匹配

项目的第一层答案是帧分析器(frame analyzer)。在 vk_frame_capture.cpp 中,BB_CAPTURE_TRIGGER=<file>触发下一帧的完整记录:每个 pass 的绘制次数、颜色/深度目标、采样的纹理、vs/ps/cs 哈希,以及对小型全屏 pass 和每个几何 pass 的第一个 draw,记录绑定常量缓冲的前 1 KiB(Buffer 函数):

void FrameCapture::Buffer(u64 stage_hash, u32 slot, VAddr address, const void* data, u64 size) { const u64 bytes = std::min<u64>(size, 1024) & ~u64(3); ... }

这一转储 + 对三作共用后处理的分析(见 upscaler.md),得到了三个以着色器哈希为键、常量缓冲为证的挂载点,最终固化在 run.py 的默认环境变量里:

环境变量值语义
BB_VELOCITY_PS=f4b18264运动模糊的相机速度通道(全屏,960×540)它的常量缓冲直接给出相机
BB_UPSCALE_BEFORE_CS=aec539f0光照场景颜色之后的第一个计算通道DLSS/FSR/TAA 在它之前运行,帧生成在这里取深度与运动
BB_HUDLESS_PS=484cdbf5写入显示缓冲、UI 之前的第一个 draw其目标即"无 UI 的画面"

这三者构成了"读心"的核心:哈希是钥匙,常量是密码本。

从常量反推相机:逆向数学还原

有了目标 pass,第二层是读懂常量缓冲本身。对三作共享的运动模糊速度通道,其像素着色器 IR 转储暴露了相机重构公式(注释直接写在 vk_camera_motion.cpp):

z = c9 / (depth - c8), view = (z (c0 u + c2), z (c1 v + c3), z) p = M view + t (M, t: rows c12..c23) previous clip x, y, w = rows c40..c43, c44..c47, c52..c55 of (p, 1)

运行时钩子OnVelocityConstants做的就是把这套布局翻译成结构化的相机对象——从常量矩阵算出"到上一帧裁剪空间"的重投影矩阵to_previous_clip、UV 到视图的映射uv_to_view和深度到 z 的映射depth_to_z(OnVelocityConstants):

camera.uv_to_view = {c[0], c[1], c[2], c[3]}; camera.depth_to_z = {c[8], c[9]};

而这条 hook 的触发点选得极巧:在光栅化器的 draw 提交处,按像素着色器哈希命中才读取常量(vk_rasterizer.cpp):

if (ps && camera_motion->IsVelocityShader(ps->pgm_hash) && !ps->buffers.empty() && !ps->buffers[0].IsSpecial()) { ... camera_motion->OnVelocityConstants( reinterpret_cast<const float*>(sharp.base_address), bytes / sizeof(float)); }

有了相机,camera_motion.comp(camera_motion.comp)在 mode 2 下重放这套数学:用深度重建视图空间位置、用重投影矩阵映射回上一帧 UV,得到像素级、上一帧减当前帧的运动向量,写入 RG16F 目标。这正是 FSR/DLSS/TAA 与帧生成共享的运动输入。

逐帧签名:场景常量作为"路标"

另一种情况不需要先识别 pass——游戏每帧都有个 864 字节的"场景常量块",几乎所有 pass 都会绑定它。它的特征极其稳定:[0]=3000 (far)、[1]=1/3000、[4]=1920、[5]=1080。OnConstants用这一签名做过滤,命中后只取每帧第一个相机(frame_has_camera保证),提取视图矩阵、逆视图与投影参数(OnConstants):

if (data[0] != 3000.0f || data[4] < 64.0f || data[5] < 64.0f || std::abs(data[1] * data[0] - 1.0f) > 1e-3f) { return; } ... std::memcpy(current.view.data(), data + 8, 12 * sizeof(float)); std::memcpy(current.inv_view.data(), data + 180, 12 * sizeof(float)); current.proj = {data[52], data[57], data[62], data[63]};

这套"签名匹配"的思路同样被用在对象运动向量上:G-buffer 顶点着色器的常量缓冲按大小分类——864 字节是场景相机块,≥640 字节是角色骨骼,96~384 且是 48 的倍数是小骨骼(武器/道具),见 motion_history.h 的ClassifyBuffer。于是"哪个 draw 在画角色、哪个在画静态地图"无需游戏源码即可判定,只有骨骼网格才走昂贵的顶点历史重放,静态网格继续用廉价相机向量——这也是从 49 FPS 提到 80+ FPS 的路径之一(详见 motion_vectors.md)。

挂载点汇合:一次 dispatch 触发整套时序管线

最后,所有线索在OnDispatch汇合(vk_temporal_upscaler.cpp):计算着色器哈希等于BB_UPSCALE_BEFORE_CS时,先为帧生成抓取深度与运动(CaptureFrameGen,与升采样器共用同一套速度通道相机),再运行升采样器:

void TemporalUpscaler::OnDispatch(u64 cs_hash) { if (cs_hash != trigger_hash || done_this_frame) return; if (frame_generator && frame_generator->Wanted() && camera_motion.VelocityMode() && !frame_gen_captured) { frame_gen_captured = true; CaptureFrameGen(); } ... Run(); }

帧生成端(vk_frame_generation.h)随后在BB_HUDLESS_PS命中时抓取"无 UI 帧",FSR 3.1 帧插值把两帧游戏画面之间的中间帧算出来,frame_gen_ui.comp再把当前帧的 UI 盖回生成帧上;DLSS FG 则把无 UI 帧作为 HUD-less 输入。这套"常量 → 相机 → 运动向量 → 挂载"的链路,构成了 60 FPS 插到 120/240 FPS 的完整底座。

方法论:可复用的移植工具箱

这套"读心术"不绑定《神秘海域》,可以抽象成可复用的步骤:

  1. 建帧分析器:记录 pass 的目标、着色器哈希、绑定常量前 1 KiB——BB_CAPTURE_TRIGGER/BB_CAPTURE_DIR即为此设计,配合tools/drive.py的shot可脚本化抓帧;
  2. 找签名常量块:全屏 pass 的常量缓冲通常含"远平面/1-远平面/渲染尺寸"这类稳定路标,用签名过滤即可锁定每帧相机;
  3. 哈希定位挂载点:把"场景颜色后的第一个 compute"、"UI 前第一个 draw"等语义锚定到具体的 cs/ps 哈希,暴露为环境变量(如BB_VELOCITY_PS、BB_UPSCALE_BEFORE_CS、BB_HUDLESS_PS);
  4. 重放数学而非逆向逻辑:从着色器 IR 转储里读出矩阵布局,在宿主侧用同样的公式重建运动向量,而不去逐条模拟游戏代码。

这套方法对任何"无源码 + 有渲染管线"的移植场景都成立——shadPS4 系的血源移植(本仓库的姊妹工程,文档同源)正是同一批代码、同一套签名字段在服务。反过来也提醒移植者:给未来留语义标记(环境变量 + 哈希白名单),是把"逆向偶然性"变成"工程确定性"的关键。下一次再遇到"往哪儿插 TAA"的问题,答案不是猜,而是读——读那一帧的常量缓冲,游戏会自己告诉你它把相机藏在了哪里。

【免费下载链接】uncharted_pcUncharted: The Nathan Drake Collection running natively on Windows - by SuperMedo项目地址: https://gitcode.com/gh_mirrors/un/uncharted_pc

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询