这篇能做出什么
读完并照着做完,你会得到一条能跑的 Vulkan 渲染链路:游戏或示例程序先以较低分辨率渲染一帧,把颜色、深度、运动矢量、曝光这几张输入送进神经超分网络,拿回一张高分辨率画面;再打开插帧,让相邻两帧之间补出一张中间帧,显示器上看到的刷新率明显提升。
具体产出是四样东西:
1. 一个编译通过、能跑起来的 OpenDLSS 示例工程;
2. 一套可复制到自己渲染器里的接入代码(抖动、运动矢量、历史复位、屏障);
3. 一组画质对比图(原生分辨率 / 双线性放大 / 神经超分),用放大截图看边缘、细线和粒子;
4. 一份帧率与延迟记录表,知道开超分和开插帧分别把帧时间和输入延迟拉到了什么水平。
这里要先说清楚一件事:DLSS 5 这一代把神经网络放进了渲染管线更靠前的位置,社区里随之出现了一些尝试在 Vulkan 上复现这套网络的开源项目,常被统称为 OpenDLSS 一类。这类项目彼此接口并不统一,有些走厂商的二进制导入扩展,有些把权重导出成通用格式后用计算着色器推理。下面用一套通用的接口约定来写,落到具体仓库时,把函数名和结构体名按仓库 README 替换即可。凡是涉及版本号、下载地址、镜像标签的地方,以官方文档当前版本为准。
前置条件清单
- 硬件:一块支持 Vulkan 1.3 及所需扩展的显卡,显存建议 8 GB 以上(历史纹理在 4K 下按 RGBA16F 算,一张约 66 MB,双缓冲就是 130 MB 左右)。
- 驱动:尽量新的官方驱动。神经推理路径对驱动版本敏感,版本太旧会出现扩展不可用或输出全黑。
- 系统与工具链:
- Vulkan SDK(含
glslc、dxc、校验层 Validation Layers) - CMake 与 Ninja(或 Make)
- 支持 C++17 及以上的编译器(MSVC / Clang / GCC)
- Python 3,用来做权重格式转换(是否需要取决于仓库)
- 调试工具:RenderDoc 或 Nsight Graphics(看资源和屏障)、ffmpeg(做逐帧画质对比)。
- 测试素材:一个能固定相机路径、固定时间步的示例场景。这一点比想象中重要,画质对比必须可复现。
环境自检先跑一遍:
```bash
vulkaninfo --summary
```
输出里确认 apiVersion 和扩展列表。找不到 vulkaninfo 说明 SDK 的环境变量没配好,先把 SDK 的 setup-env 脚本 source 一遍(Windows 下是运行对应的批处理)。
```bash
glslc --version
cmake --version
```
第一步:先搞清它替换了管线里的哪一段
不要一上来就改代码。先明确数据流:
```
低分辨率颜色 + 深度 + 运动矢量 + 曝光 + jitter
│
▼
神经超分网络 ──→ 高分辨率输出
│
└──→ 更新历史纹理(供下一帧重投影使用)
```
超分不是简单放大。它靠时间累积:用运动矢量把上一帧的高分辨率历史重投影到当前帧,用深度判断哪些像素是新露出来的(需要丢弃历史),然后让网络去补细节。所以输入里的任何一项错了,出来的画面要么糊、要么拖影、要么闪。
插帧是另一条链路,输入是两张已经渲染好的连续帧加它们的运动信息,输出夹在中间的合成帧。它提升的是显示流畅度,不改变渲染逻辑。
先在纸上确认你的渲染器里这几样东西是否齐备:
- 有没有逐像素运动矢量?很多手写渲染器只有相机矩阵,没有物体级运动,那超分效果会很有限。
- 有没有深度缓冲?是否是反向 Z?近远平面怎么给?
- 有没有曝光值?没有的话给 1.0,但要在接口里说明。
第二步:编译并跑通示例
按仓库 README 走,通常是这套流程:
```bash
git clone <仓库地址> OpenDLSS
cd OpenDLSS
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
```
如果配置阶段报找不到 Vulkan,手动指定:
```bash
cmake -S . -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DVulkan_INCLUDE_DIR=$VULKAN_SDK/include \
-DVulkan_LIBRARY=$VULKAN_SDK/lib
```
先别急着接自己的项目,把自带的示例跑起来,确认三件事:窗口能出画面、切换超分档位有反应、关闭超分回到原生渲染不崩。示例跑不通,接进自己管线只会更难定位。
跑的时候打开校验层,日志会救命:
```bash
VK_LOADER_DEBUG=all VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation ./build/opendlss_demo
```
第三步:把超分接进自己的渲染管线
3.1 准备输入纹理
超分接口通常需要这几张图,尺寸和格式按仓库文档确认:
| 输入 | 说明 |
|---|---|
| 颜色 | 低分辨率,线性空间,建议 FP16 |
| 深度 | 与颜色同分辨率,注意 Z 方向约定 |
| 运动矢量 | 与颜色同分辨率,RG 两个通道 |
| 曝光 | 1×1 或与颜色同尺寸,视接口而定 |
| 输出 | 高分辨率,与交换链同尺寸 |
运动矢量最常见的两个坑:单位不统一、方向反了。写个可视化着色器立刻能看出来——正常的运动矢量在相机平移时应该是一片有方向性的色带,如果全是灰色(0,0)说明根本没写。
```glsl
// mvec_debug.frag:把运动矢量放大后直接画出来
#version 450
layout(location = 0) in vec2 vUV;
layout(set = 0, binding = 0) sampler2D uMotion;
layout(location = 0) out vec4 oColor;
void main() {
vec2 mv = texture(uMotion, vUV).rg;
// 放大 20 倍便于观察,0.5 为中性灰
oColor = vec4(mv * 20.0 + 0.5, 0.0, 1.0);
}
```
3.2 抖动:小偏移换来高分辨率细节
超分靠亚像素抖动来补齐高分辨率下缺失的采样点,这一项漏了,画质提升会大打折扣。
```cpp
// 用 Halton(2,3) 序列产生落在 [-0.5, 0.5] 像素内的偏移
static float halton(int index, int base) {
float f = 1.0f, r = 0.0f;
int i = index;
while (i > 0) {
f /= static_cast<float>(base);
r += f * static_cast<float>(i % base);
i /= base;
}
return r;
}
// frameIndex 每帧递增,复位历史时归零
const float jitterX = halton(frameIndex, 2) - 0.5f;
const float jitterY = halton(frameIndex, 3) - 0.5f;
```
抖动偏移要同时做两件事:写进投影矩阵的平移项,并把原始偏移值传给超分接口。注意单位——这里的偏移是输出分辨率的像素,如果你的投影矩阵按低分辨率计算,记得换算。
```cpp
// proj 是原始投影矩阵,w/h 是低分辨率渲染目标的宽高
proj[12] += (jitterX * 2.0f) / static_cast<float>(w);
proj[13] += (jitterY * 2.0f) / static_cast<float>(h);
```
3.3 提交与屏障
推理前,低分辨率颜色、深度、运动矢量必须处于可读状态;推理后,输出纹理要转到可写/可采样状态。用同步 2 的接口写会比较清爽:
```cpp
VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};
barrier.srcStageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
barrier.srcAccessMask = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
barrier.dstStageMask = VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT;
barrier.dstAccessMask = VK_ACCESS_2_SHADER_SAMPLED_READ_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.image = colorImage;
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
dep.imageMemoryBarrierCount = 1;
dep.pImageMemoryBarriers = &barrier;
vkCmdPipelineBarrier2(cmd, &dep);
```
屏障少一个的典型症状是画面随机闪烁或者偶尔出一帧全黑,这类问题在 RenderDoc 里抓一帧往往看不出来,要连着抓几帧对比。
3.4 历史复位
历史纹理是超分画质的来源,也是鬼影的来源。以下情况必须调用复位接口或直接丢弃历史:
- 相机切镜头、传送、加载新场景;
- 交换链重建、分辨率变化;
- 渲染目标尺寸变化;
- 从暂停恢复。
漏掉这一步的表现是:切场景后画面会拖着一层上一场景的残影,一两秒才淡掉。
第四步:接入插帧
插帧的接入比超分简单,但限制更多:
1. 需要两张已经渲染完成的帧,以及它们之间的运动信息(部分实现自己算光流,部分要你提供);
2. 需要控制呈现节奏(pacing)。生成帧提交的时机不对,会出现节奏抖动,看起来比不开还难受;
3. UI 必须单独处理。HUD、准星、字幕如果在插帧之前画进场景,会被插帧算法当成运动物体拉伸变形。常见做法是把 UI 放到插帧之后单独合成;
4. 垂直同步和可变刷新率的组合会影响体验,逐个组合试一遍。
一个务实的接入顺序:先只开超分,把帧率和画质调稳;确认稳定后再单独开插帧,观察帧时间曲线是否平整;最后再同时开。
第五步:画质对比
固定相机路径和随机种子,分别导出三组序列帧:原生高分辨率、低分辨率双线性放大、神经超分。命名对齐后用 ffmpeg 算客观指标:
```bash
单帧 PSNR / SSIM
ffmpeg -i ref_0001.png -i sr_0001.png -lavfi psnr -f null -
ffmpeg -i ref_0001.png -i sr_0001.png -lavfi ssim -f null -
整段序列
ffmpeg -i ref/%04d.png -i sr/%04d.png -lavfi psnr -f null -
```
指标只做参考,真正要看的是这几类区域,建议用截图工具放大到 200% 以上逐处对比:
- 细长几何:栏杆、电线、栅栏,看有没有断裂或蠕动;
- 高对比边缘:看有没有过冲产生的白边;
- 粒子与半透明:烟雾、火花、玻璃,看有没有拖尾;
- 快速运动场景:看有没有鬼影。
再做一个动态测试:镜头快速横移时录屏,逐帧看边缘是否稳定。静态截图好看、动态一塌糊涂,是超分接入里很常见的状态,多半是运动矢量或抖动出了问题。
第六步:帧率与延迟实测
帧率用 GPU 时间戳测,比 CPU 侧计时准确:
```cpp
// 记录超分前后的 GPU 时间,单位纳秒
vkCmdWriteTimestamp2(cmd, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, queryPool, 0);
// ... 提交超分推理 ...
vkCmdWriteTimestamp2(cmd, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, queryPool, 1);
```
延迟要看的是"输入到光子"的时间,不是帧率倒数。两条路:
- 用厂商提供的低延迟扩展(Vulkan 侧的
VK_NV_low_latency2一类)插入延迟标记,从工具里读数值,具体接口以官方文档为准; - 或者用高速摄像机的土办法:屏幕上放一个随按键变色的大方块,摄像机同时拍手和屏幕,数帧差。
记录表按这个格式填,每一行是同一场景同一段路径:
| 配置 | 平均帧率 | 1% 低帧 | 帧生成耗时 | 输入延迟 |
|---|---|---|---|---|
| 原生高分辨率 | — | |||
| 低分辨率 + 双线性 | — | |||
| 低分辨率 + 超分 | ||||
| 超分 + 插帧 |
插帧那一行要特别留意:显示帧率上去了,输入延迟不一定下降,甚至可能变大。如果延迟明显上升,先检查呈现节奏和帧队列深度,这两处是常见原因。
常见坑与排错
输出全黑或全白。 先确认输入纹理的布局是否正确,再确认颜色空间。很多网络要求线性空间输入,如果你的渲染目标是 sRGB 格式,需要显式转换。
画面整体发灰、对比度变低。 多半是曝光值没传对,或者输出被做了两次色调映射。
边缘有拖影。 依次检查:运动矢量方向是否反了、单位是否一致、抖动是否写进了矩阵、深度是否按接口要求做线性化。
画面周期性抖动。 抖动序列没复位,或者历史纹理没有双缓冲。
切场景出现残影。 参考 3.4 节,在切换点调用复位。
扩展不可用。 先跑 vulkaninfo 确认扩展在列表里;不在的话升级驱动;仍不支持就要走通用推理回退路径,性能会差不少。
显存吃紧。 历史纹理和中间缓冲是大头,先降推理分辨率或让历史纹理用 FP16,别急着砍网络层数。
插帧后画面节奏不稳。 检查呈现模式、帧队列深度、以及生成帧的提交时机,这三者要一起调。
校验层报 VUID 错误。 不要绕过,绝大多数图形问题最后都能追溯到这些报错上。
下一步建议
跑通之后,可以往这几个方向继续:
1. 做 A/B 开关工具。 在运行时用快捷键切换原生 / 超分 / 插帧,配合帧时间曲线,比看截图更容易发现问题。
2. 做超分参数扫描。 把低分辨率渲染比例、锐化强度、历史权重做成可调参数,找到你项目里画质和性能的平衡点。
3. 接进真实的复杂场景。 示例场景通常干净得过分。拿一个半透明材质多、粒子多、动态物体多的场景再跑一遍,问题才会暴露。
4. 补上运动矢量的覆盖率。 如果项目里只有相机运动矢量,把蒙皮网格、骨骼动画、粒子系统的运动矢量补上,超分画质会有明显改善。
5. 建立回归测试。 把固定相机路径的回放和 PSNR/SSIM 对比脚本固化成一条命令,每次改渲染管线都跑一遍,避免改动悄悄劣化画质。
最后提醒一句:这类开源重实现和厂商官方实现之间,在画质、性能、兼容性上都会有差距,具体数字会随驱动版本和硬件变化,以自己机器上的实测为准。
