
1. 为什么游戏开发者要自己动手写NVENC编码器很多做游戏开发的朋友第一次听到“自己构建NVENC编码器”这个说法第一反应是这不是显卡驱动或者OBS那种录屏软件才管的事吗我一个做玩法的为什么要碰编码器我当初也是这么想的直到项目里遇到一个绕不开的需求——游戏内实时回放和精彩时刻自动剪辑。用CPU软编码1080p60帧直接把主线程吃掉三成帧率从144掉到90玩家骂声一片。换成显卡自带的硬件编码单元之后同样的画质下CPU占用几乎归零帧生成时间稳如老狗。这就是NVENC存在的意义它把视频编码这件事从通用计算单元里剥离出来交给GPU上一块专门干这个的硅片。NVENC是NVIDIA显卡上的独立硬件编码模块从Kepler架构开始就存在到现在已经迭代了七八代。它不占用CUDA核心不抢图形渲染的算力独立供电独立调度。对于游戏开发者来说这意味着你可以在渲染管线的末端直接挂一个编码任务把帧缓冲送进去出来的就是H.264或者H.265的码流整个过程对游戏性能的影响可以控制在个位数百分比以内。这篇文章要聊的就是怎么从零开始用DirectX把游戏画面抓出来喂给NVENC最后拿到一个能播的视频文件。适合有C基础、了解DirectX基本概念、想在游戏里集成录制或直播功能的开发者。如果你连D3D11的device和context都还没分清楚建议先把基础补一补再来看不然中间会卡得比较难受。我写这份手册的出发点很简单网上关于NVENC的资料要么是API文档式的罗列要么是FFmpeg命令行的一行流真正从游戏引擎视角讲清楚“帧从哪来、怎么传、编码参数怎么调”的内容少得可怜。我自己踩过的坑包括但不限于纹理格式不匹配导致编码器直接报错、显存泄漏跑十分钟就爆、时间戳对不上导致音画不同步、码率控制模式选错让画面糊成马赛克。这些东西文档里不会写但实际项目里一个都躲不掉。下面我把整个流程拆开从架构设计到代码落地再到问题排查尽量把每个环节的“为什么”讲透。2. 整体架构设计与核心思路拆解2.1 从游戏帧到视频文件的数据链路整个系统的数据流其实不复杂但每一步都有讲究。游戏引擎每帧渲染完成后颜色缓冲区里是一张DirectX纹理。这张纹理需要被复制到一块NVENC能够访问的资源上然后编码器把它压缩成码流最后码流被写入文件或者推流。听起来就三步但中间涉及跨API的资源共享、显存与系统内存的搬运、以及编码器会话的管理。我选择的方案是D3D11渲染到纹理通过NVENC的D3D11互操作接口直接注册这块纹理编码器直接从显存读取避免回读到系统内存再上传的往返开销。这个选择的核心考量是延迟和带宽。如果走CPU回读一张1080p的RGBA纹理是8MB左右60帧就是每秒480MB的PCIe传输量虽然PCIe 3.0 x16理论带宽够但实际占用和延迟都会影响游戏主线程。直接显存互操作则把这一步省掉了编码器通过DMA直接读显存CPU只需要发指令。另一个关键决策是编码器会话的创建时机。NVENC的会话创建有开销大概几十毫秒如果在游戏过程中频繁创建销毁会造成明显的卡顿。我的做法是在游戏启动时或者第一次触发录制时创建好会话之后一直复用直到游戏退出或者用户手动停止录制。会话内部维护自己的输入缓冲队列和输出缓冲队列通过事件或者轮询的方式驱动。2.2 为什么选DirectX而不是OpenGL或Vulkan热词里出现了DirectX修复工具、DirectX 12不支持之类的搜索说明很多人在Windows平台上做图形开发时绕不开DirectX。NVENC的互操作接口对D3D11和D3D12都有支持OpenGL也有对应的扩展但游戏开发场景下D3D11仍然是兼容性和开发效率的平衡点。D3D12更底层控制更精细但代码量大调试困难对于录制这种非核心功能来说投入产出比不高。D3D11的纹理共享机制成熟NVENC的D3D11互操作接口文档也最完善遇到问题容易找到参考。具体来说NVENC的D3D11互操作通过nvEncodeAPI.h里的NV_ENC_REGISTER_RESOURCE结构体完成。你需要把D3D11纹理的指针传进去指定资源类型为NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX编码器会返回一个句柄后续编码时直接引用这个句柄。这里有个细节纹理的格式必须是NVENC支持的比如DXGI_FORMAT_B8G8R8A8_UNORM或者DXGI_FORMAT_R8G8B8A8_UNORM如果是其他格式需要先做一次格式转换要么用计算着色器要么用视频处理器接口。2.3 编码参数的选择逻辑NVENC的编码参数有一大堆但真正影响游戏录制体验的就那么几个码率控制模式、GOP长度、预设级别、编码格式。码率控制我推荐用NV_ENC_PARAMS_RC_CBR或者NV_ENC_PARAMS_RC_VBR前者适合直播推流后者适合本地录制。CBR的好处是码率稳定网络传输不会因为码率波动导致卡顿VBR在复杂场景下会给更多码率画质更好但文件大小不可预测。GOP长度决定了关键帧的间隔。关键帧是独立编码的不依赖其他帧所以 seeking 的时候只能跳到关键帧。对于游戏录制我一般设GOP为帧率的两倍比如60帧就设120这样每两秒有一个关键帧回放拖动时间轴的时候定位精度够用同时不会因为关键帧太多导致码率浪费。预设级别从NV_ENC_PRESET_P1到P7P1最快画质最低P7最慢画质最高。游戏录制我通常用P4或者P5在画质和性能之间取平衡。实测下来P4在1080p60下的GPU额外占用大概在3%到5%P7会到8%以上对于同时跑游戏的机器来说P4是更稳妥的选择。编码格式选H.264还是H.265取决于你的播放端。H.264兼容性无敌什么设备都能播H.265同码率下画质更好但老设备可能不支持。如果是游戏内回放播放端就是游戏自己那用H.265没问题如果要导出分享给朋友H.264更省心。3. 核心细节解析与实操要点3.1 NVENC SDK的获取与项目配置NVENC SDK不在显卡驱动里直接提供需要去NVIDIA的开发者网站下载。下载下来是一个压缩包里面有头文件、导入库和示例代码。头文件主要是nvEncodeAPI.h导入库是nvencodeapi.lib运行时依赖nvEncodeAPI64.dll这个DLL在显卡驱动安装目录下通常不需要自己分发。项目配置的时候有几个坑要注意。首先nvEncodeAPI.h需要定义NVENCAPI_VERSION宏这个宏的值要和驱动版本匹配不然NvEncodeAPICreateInstance会返回版本不匹配的错误。我一般直接用SDK里带的版本号比如NVENCAPI_MAJOR_VERSION 12和NVENCAPI_MINOR_VERSION 0。其次链接的时候要把nvencodeapi.lib加到附加依赖项里同时确保编译目标是64位因为32位的NVENC支持在新驱动上已经砍掉了。还有一个容易忽略的点NVENC的API是C风格的所有函数通过一个NV_ENCODE_API_FUNCTION_LIST结构体暴露出来。你需要先调用NvEncodeAPICreateInstance拿到这个结构体之后所有操作都通过结构体里的函数指针进行。这种设计是为了避免和驱动里的其他编码器冲突但也意味着你不能直接#include之后就调函数必须走函数列表。3.2 D3D11纹理的注册与格式匹配把D3D11纹理注册给NVENC是整个流程里最容易出错的一步。首先纹理必须是D3D11_USAGE_DEFAULT或者D3D11_USAGE_STAGING不能是D3D11_USAGE_DYNAMIC。其次纹理不能是多重采样的如果游戏用了MSAA需要先resolve到一张非MSAA的纹理上。第三纹理的绑定标志要包含D3D11_BIND_RENDER_TARGET或者D3D11_BIND_SHADER_RESOURCE具体取决于你的使用方式。注册的时候填NV_ENC_REGISTER_RESOURCE结构体NV_ENC_REGISTER_RESOURCE reg {}; reg.version NV_ENC_REGISTER_RESOURCE_VER; reg.resourceType NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX; reg.resourceToRegister pTexture; reg.width width; reg.height height; reg.pitch 0; // D3D11纹理不需要指定pitch reg.bufferFormat NV_ENC_BUFFER_FORMAT_ARGB; reg.bufferUsage NV_ENC_INPUT_IMAGE;这里bufferFormat要和纹理的实际格式对应。如果纹理是DXGI_FORMAT_B8G8R8A8_UNORM对应的NV_ENC_BUFFER_FORMAT是NV_ENC_BUFFER_FORMAT_ARGB。如果纹理是DXGI_FORMAT_R8G8B8A8_UNORM对应的是NV_ENC_BUFFER_FORMAT_ABGR。这个对应关系搞错了编码出来的画面颜色会不对比如红蓝通道互换。注册成功后编码器会返回一个registeredResource句柄。后续编码时把这个句柄填到NV_ENC_MAP_INPUT_RESOURCE结构体里再映射到编码器的输入缓冲区。映射完成后你就可以调用nvEncEncodePicture进行编码了。编码完成后要记得nvEncUnmapInputResource不然显存会泄漏。3.3 编码会话的初始化参数详解创建编码会话用nvEncOpenEncodeSessionEx传入NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS结构体。这个结构体里最关键的是deviceType和device。deviceType设为NV_ENC_DEVICE_TYPE_DIRECTXdevice传你的D3D11设备指针。这样编码器就和你的渲染设备绑定在同一个上下文里可以直接访问显存资源。会话创建之后需要配置编码参数。NV_ENC_INITIALIZE_PARAMS结构体里包含encodeGUID、presetGUID、encodeWidth、encodeHeight、frameRateNum、frameRateDen等。encodeGUID选NV_ENC_CODEC_H264_GUID或者NV_ENC_CODEC_HEVC_GUID。presetGUID选NV_ENC_PRESET_P4_GUID。帧率用分数表示比如60帧就是frameRateNum60, frameRateDen129.97帧就是frameRateNum30000, frameRateDen1001。配置完NV_ENC_INITIALIZE_PARAMS之后还要配置NV_ENC_CONFIG这里面是码率、GOP、QP等具体参数。NV_ENC_CONFIG里有一个rcParams子结构体rateControlMode设NV_ENC_PARAMS_RC_CBRaverageBitRate设目标码率比如10Mbps就是10000000。gopLength设120frameIntervalP设1表示没有B帧设2表示有一个B帧。B帧能提高压缩率但增加延迟游戏录制我一般设1保证低延迟。注意NV_ENC_CONFIG的version字段必须正确设置不同版本的SDK结构体布局可能不同版本号填错会导致初始化失败或者参数被忽略。3.4 输入缓冲区的管理与同步NVENC的输入缓冲区是通过NV_ENC_CREATE_INPUT_BUFFER创建的但如果你用的是D3D11互操作就不需要自己创建输入缓冲区而是直接映射已注册的纹理资源。映射的时候用NV_ENC_MAP_INPUT_RESOURCE传入之前注册得到的registeredResource句柄编码器会返回一个mappedResource句柄。这里有一个同步问题游戏渲染和编码器读取是异步的。如果编码器还在读上一帧的纹理游戏就开始渲染下一帧到同一张纹理上画面就会撕裂或者编码出错误的内容。解决办法是使用双缓冲或者三缓冲准备两张或三张纹理渲染完一张就交给编码器编码器用完一张就还给渲染管线。NVENC的nvEncEncodePicture是异步的调用后立即返回你需要通过nvEncLockBitstream来获取编码结果这个调用会阻塞直到编码完成。所以实际的同步点是nvEncLockBitstream在这之前纹理不能被复用。我通常的做法是维护一个纹理池池子大小设为3。渲染线程从池子里取一张空闲纹理渲染完成后标记为“待编码”编码线程从待编码队列里取纹理编码完成后标记为“空闲”。这样即使编码偶尔慢一两帧也不会阻塞渲染。4. 实操过程与核心环节实现4.1 环境准备与依赖安装开始写代码之前先把环境搭好。你需要Windows 10或11一张支持NVENC的NVIDIA显卡GTX 600系列以上但建议GTX 1000系列以上因为老架构的NVENC画质和性能都差很多Visual Studio 2019或2022Windows SDK以及NVENC SDK。NVENC SDK的下载地址在NVIDIA开发者网站搜索“NVENC Video Codec SDK”就能找到。下载下来解压里面Interface目录下是头文件Lib目录下是导入库。把Interface目录加到项目的附加包含目录把Lib/x64加到附加库目录然后在链接器输入里加上nvencodeapi.lib。显卡驱动要更新到比较新的版本老驱动可能不支持最新的SDK版本。我遇到过驱动太老导致NvEncodeAPICreateInstance返回NV_ENC_ERR_UNSUPPORTED_DEVICE的情况更新驱动就好了。另外如果机器上有多个GPU比如核显加独显要确保D3D11设备创建在NVIDIA显卡上不然NVENC会话创建会失败。4.2 初始化D3D11设备与NVENC会话D3D11设备的创建没什么特别的但要注意一点如果你用的是D3D11创建设备时不要指定D3D11_CREATE_DEVICE_DEBUG标志除非你在调试因为这个标志在某些驱动版本下会和NVENC冲突。设备创建好之后拿到ID3D11Device和ID3D11DeviceContext。NVENC会话的初始化分三步创建实例、打开会话、配置参数。创建实例调用NvEncodeAPICreateInstance传入NV_ENCODE_API_FUNCTION_LIST结构体的指针。打开会话调用nvEncOpenEncodeSessionEx传入D3D11设备指针。配置参数调用nvEncInitializeEncoder传入NV_ENC_INITIALIZE_PARAMS。NV_ENCODE_API_FUNCTION_LIST fnList { NV_ENCODE_API_FUNCTION_LIST_VER }; NVENCSTATUS status NvEncodeAPICreateInstance(fnList); if (status ! NV_ENC_SUCCESS) { // 处理错误通常是驱动版本不匹配 } NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS sessionParams {}; sessionParams.version NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER; sessionParams.deviceType NV_ENC_DEVICE_TYPE_DIRECTX; sessionParams.device pD3D11Device; sessionParams.apiVersion NVENCAPI_VERSION; void* encoder nullptr; status fnList.nvEncOpenEncodeSessionEx(sessionParams, encoder);会话打开后配置NV_ENC_INITIALIZE_PARAMS和NV_ENC_CONFIG然后调用nvEncInitializeEncoder。这一步如果返回NV_ENC_ERR_INVALID_PARAM大概率是某个结构体的version字段没填对或者encodeGUID和presetGUID的组合不被支持。4.3 纹理注册与编码循环的实现纹理注册的代码前面已经给过了这里补充一个细节注册的纹理尺寸必须是偶数因为NVENC的编码单元是宏块宽高不是偶数的话需要padding。我一般直接把渲染纹理的尺寸设成偶数比如1920x1080避免额外的处理。编码循环的核心逻辑是每帧渲染完成后把纹理注册资源映射到编码器输入调用nvEncEncodePicture然后锁定码流获取编码结果。nvEncEncodePicture的参数是NV_ENC_PIC_PARAMS结构体里面要填inputBuffer、outputBitstream、pictureType、frameIdx等。pictureType对于普通帧填NV_ENC_PIC_TYPE_P关键帧填NV_ENC_PIC_TYPE_I。frameIdx是递增的帧序号用来做时间戳。NV_ENC_MAP_INPUT_RESOURCE mapRes {}; mapRes.version NV_ENC_MAP_INPUT_RESOURCE_VER; mapRes.registeredResource registeredResource; status fnList.nvEncMapInputResource(encoder, mapRes); NV_ENC_PIC_PARAMS picParams {}; picParams.version NV_ENC_PIC_PARAMS_VER; picParams.inputBuffer mapRes.mappedResource; picParams.bufferFmt NV_ENC_BUFFER_FORMAT_ARGB; picParams.inputWidth width; picParams.inputHeight height; picParams.outputBitstream outputBuffer; picParams.pictureType NV_ENC_PIC_TYPE_P; picParams.frameIdx frameIndex; picParams.inputTimeStamp timestamp; status fnList.nvEncEncodePicture(encoder, picParams);编码完成后调用nvEncLockBitstream获取码流数据。这个函数会阻塞直到编码完成返回一个NV_ENC_LOCK_BITSTREAM结构体里面包含码流指针和大小。把码流写进文件或者推流之后调用nvEncUnlockBitstream释放再调用nvEncUnmapInputResource解除映射。4.4 码流封装与文件输出NVENC输出的是裸码流H.264的话是Annex B格式每个NALU前面有起始码00 00 00 01。要生成一个能播的MP4文件还需要封装容器。自己写MP4封装器工作量不小我建议直接用FFmpeg的libavformat来做封装。把NVENC输出的码流喂给av_write_frameFFmpeg会处理容器格式、时间戳、索引等细节。用FFmpeg封装的时候要注意时间戳的基准。NVENC的inputTimeStamp单位是纳秒FFmpeg的AVPacket的pts和dts单位是AVStream的time_base。我一般把time_base设为{1, 1000000000}这样NVENC的时间戳可以直接用。另外H.264的SPS和PPS需要在第一帧之前写入NVENC会在第一个关键帧的码流里带上SPS和PPSFFmpeg能自动提取不需要额外处理。如果不想引入FFmpeg的依赖也可以自己写一个简单的MP4封装器但工作量大概在两三天左右而且容易在moov atom的位置、stco表的偏移量这些细节上出错。对于游戏内录制这种场景我倾向于用FFmpeg省时省力。5. 常见问题与排查技巧实录5.1 编码器初始化失败的各种原因初始化失败是最常见的问题表现是nvEncOpenEncodeSessionEx或者nvEncInitializeEncoder返回错误码。我整理了一个排查表错误码可能原因解决办法NV_ENC_ERR_UNSUPPORTED_DEVICE显卡不支持NVENC或驱动太老更新驱动确认显卡型号NV_ENC_ERR_INVALID_VERSIONSDK版本和驱动不匹配下载匹配的SDK版本NV_ENC_ERR_INVALID_PARAM结构体version字段错误检查所有结构体的version赋值NV_ENC_ERR_OUT_OF_MEMORY显存不足关闭其他占用显存的程序NV_ENC_ERR_GENERIC驱动内部错误重启机器更新驱动还有一个隐蔽的问题如果D3D11设备创建在了核显上NVENC会话会创建失败因为核显上没有NVENC单元。解决办法是在创建D3D11设备时枚举适配器选择NVIDIA的那一个。用IDXGIFactory::EnumAdapters遍历找到VendorId为0x10DE的适配器。5.2 画面颜色异常与花屏问题颜色异常通常有两个原因纹理格式和bufferFormat不匹配或者纹理的sRGB标志没处理好。如果纹理是DXGI_FORMAT_B8G8R8A8_UNORM_SRGB注册的时候bufferFormat要填NV_ENC_BUFFER_FORMAT_ARGB但编码器不会做sRGB到线性的转换所以颜色可能会偏暗。解决办法是在渲染到纹理的时候就用线性格式或者在着色器里手动做转换。花屏问题一般是纹理复用导致的。如果编码器还在读纹理渲染线程就写了新内容编码出来的画面就会撕裂。用双缓冲或者三缓冲可以解决。另外如果纹理的mipmap级别大于1注册的时候要指定mipLevel为0不然编码器可能读到错误的mip级别。5.3 性能瓶颈的定位与优化如果发现开启录制后游戏帧率下降明显先定位瓶颈在哪。用GPUView或者PresentMon看GPU占用和帧生成时间。如果GPU占用增加很多可能是编码预设太高降到P3或P4试试。如果CPU占用高可能是码流回读或者封装环节的问题检查是不是在CPU上做了颜色转换或者缩放。还有一个容易被忽略的点nvEncLockBitstream是阻塞调用如果编码器输出队列满了这个调用会等待。如果游戏帧率高于编码器处理速度输出队列会堆积最终导致nvEncEncodePicture也阻塞。解决办法是限制录制帧率比如游戏跑144帧但只录60帧每两帧取一帧送给编码器。或者用异步编码把编码放在单独的线程里渲染线程只负责把纹理放进队列。5.4 音画同步的处理经验游戏录制通常还要录声音音画同步是个大问题。音频的采样率是固定的比如48000Hz视频帧率是60fps两者不是整数倍关系。我的做法是以音频时间为基准视频帧的时间戳根据音频时钟来调整。具体来说维护一个音频时钟每编码一帧视频查询当前音频时钟把视频帧的时间戳设为音频时钟的值。这样即使视频帧率有波动音画也不会漂移。如果音频和视频分别用不同的线程采集还要注意加锁保护共享的时间戳变量。我一般用一个std::atomicint64_t来存音频时钟视频线程读音频线程写避免锁的开销。6. 进阶优化与扩展思路6.1 多路编码与码率自适应如果游戏需要同时录制多个视角比如主视角加小地图可以用多个NVENC会话并行编码。NVENC支持同时开多个会话但数量受显卡限制消费级显卡一般支持2到3个并发会话。每个会话独立配置码率和分辨率互不干扰。码率自适应是另一个优化方向。如果游戏场景变化剧烈固定码率会导致复杂场景画质下降。可以根据帧的复杂度动态调整码率比如用前一帧的编码大小来预测下一帧的码率需求。NVENC的VBR模式已经内置了类似机制但你可以通过NV_ENC_RECONFIGURE_PARAMS在运行时调整码率上限。6.2 与游戏引擎的深度集成把NVENC集成到Unity或Unreal引擎里需要考虑引擎的渲染管线。Unity的OnRenderImage或者RenderPipelineManager.endCameraRendering回调是插入编码逻辑的好位置。Unreal的话可以用SceneViewExtension或者自定义ViewExtension。关键是要拿到渲染完成后的FRHITexture然后转换成D3D11纹理指针注册给NVENC。引擎集成还有一个坑引擎可能自己管理纹理池纹理的创建和销毁不受你控制。解决办法是创建一个自己的纹理作为中转每帧把引擎的渲染结果拷贝到自己的纹理上再注册给NVENC。多一次拷贝会增加一点开销但换来了稳定性和可控性。6.3 录制文件的后期处理录出来的裸码流或者MP4文件可能还需要后期处理比如裁剪、加水印、转码。这些用FFmpeg命令行就能搞定不需要在游戏里做。我一般把录制文件存成MKV格式因为MKV对损坏的容忍度比MP4高即使录制过程中崩溃已经写入的部分还能播放。录制完成后再用FFmpeg转成MP4ffmpeg -i input.mkv -c copy output.mp4无损转换速度很快。如果要做精彩时刻自动剪辑可以在编码的时候记录每帧的时间戳和游戏内事件比如击杀、得分后期根据事件时间点从录制文件里截取片段。NVENC的码流里每个关键帧都有时间戳截取的时候从关键帧开始保证片段能独立播放。7. 我踩过的那些坑与最终建议回过头看从零构建NVENC编码器这件事技术难度其实集中在几个点上D3D11和NVENC的互操作、纹理生命周期的管理、时间戳的同步。把这三个问题解决了剩下的就是体力活。我最初版本没有用纹理池每帧创建销毁纹理跑五分钟就显存泄漏崩溃。后来改成三缓冲池稳定跑了八个小时的测试没出问题。另一个让我头疼的是驱动兼容性。不同版本的NVIDIA驱动对NVENC API的支持有细微差别比如某些驱动版本对NV_ENC_BUFFER_FORMAT_ABGR的支持有问题编码出来颜色不对。我的建议是锁定一个稳定的驱动版本在项目里做好版本检测如果驱动太老就提示用户更新而不是直接崩溃。如果你刚开始做我建议先用NVENC SDK里自带的示例代码跑通流程那个示例叫NvEncoder在Samples目录下。把示例跑起来确认环境没问题再往自己的项目里移植。移植的时候不要一次性全搬先跑通单帧编码再加循环再加音频一步一步来。每步都验证输出是否正确比最后一起调试要轻松得多。最后说一个实际项目里的经验录制功能对游戏性能的影响玩家是很敏感的。哪怕只掉5帧也会有人反馈。所以编码参数不要贪心P4预设加CBR 8Mbps1080p60这个配置在大多数机器上性能影响都在可接受范围内。如果玩家机器配置高可以在设置里提供“高质量录制”选项用P6加15Mbps让玩家自己选。把选择权交给玩家比你自己拍脑袋定一个参数要稳妥。