资源管理模块的实践开发日志
一、从图到代码
上篇我把资源管理模块的设计思路理了一遍:全局单例、五个状态的帧状态机、用哈希做纹理弱引用。那会儿觉得自己想得挺明白的,真坐到电脑前开始写第一行 `std::mutex` 的时候才知道,想明白和写出来之间隔了起码十个坑。
这篇记录的,就是我把那些图变成代码的过程,以及中间碰到的各种意料之外的问题。
二、搭骨架:单例和状态机
先从最简单的开始。全局单例没什么好说的,Meyer's Singleton 几行写完:
class ResourceStateManager {
public:
static ResourceStateManager& Get() {
static ResourceStateManager instance;
return instance;
}
// 禁止拷贝
ResourceStateManager(const ResourceStateManager&) = delete;
ResourceStateManager& operator=(const ResourceStateManager&) = delete;
private:
ResourceStateManager() = default;
std::mutex mtx;
FrameState state = FrameState::IDLE;
std::unordered_map<TextureSlot, TrackedTexture> textures;
};
`Get()` 就是全局入口,任何一个钩子函数里都能直接 `ResourceStateManager::Get()` 拿到实例。这步很顺利。
状态机的五个状态我用了一个简单的枚举:
enum class FrameState {
IDLE,
WAITING_RESOURCES,
READY,
PROCESSING,
FRAME_END
};
状态流转的逻辑我一开始写得很粗暴——在每个资源注册函数里硬改状态。比如收到深度缓冲的时候,如果当前是 `IDLE`,就切到 `WAITING_RESOURCES`;所有必须纹理到齐了,就切到 `READY`。当时觉得没啥问题,测了两款游戏也确实没崩。
但第三款游戏就翻了。
测的是《地狱之刃:塞娜的献祭》,一款虚幻引擎的游戏。它的渲染管线里深度缓冲会创建两次——一次在主场景,一次在UI层。我的代码在收到第二份深度缓冲时,状态已经是 `WAITING_RESOURCES` 了,然后纹理映射表里 `TextureSlot::DEPTH` 被更新成了第二份深度缓冲。可这第二份是UI层的,分辨率比场景层低。等运动矢量到齐、状态切到 `READY`、上采样真正执行的时候,深度缓冲是UI层的分辨率,FSR 后端直接抛了异常。
这个问题让我意识到,纹理追踪不能只看“有没有”,还得看“对不对”。如果同一个槽位被多次注册,到底该保留第一份还是第二份,不能一概而论。
后来我在纹理注册函数里加了一层校验,拿到新纹理的时候和已有的比较一下分辨率,取大的那个,并且打一条日志。这也不是什么优雅方案,但目前够用:
void ResourceStateManager::RegisterTexture(TextureSlot slot,
ID3D12Resource* resource,
const D3D12_RESOURCE_DESC& desc) {
std::lock_guard<std::mutex> lock(mtx);
if (state == FrameState::IDLE) {
state = FrameState::WAITING_RESOURCES;
}
auto it = textures.find(slot);
if (it != textures.end()) {
// 同一帧里这个槽位已经有纹理了,取分辨率大的
const auto& oldDesc = it->second.desc;
if (desc.Width <= oldDesc.Width && desc.Height <= oldDesc.Height) {
// 新的没大的大,忽略
return;
}
Log("Slot %d overwritten: %dx%d -> %dx%d",
(int)slot, oldDesc.Width, oldDesc.Height, desc.Width, desc.Height);
}
TrackedTexture tracked;
tracked.resource = resource;
tracked.desc = desc;
tracked.frameNumber = currentFrame;
textures[slot] = tracked;
CheckReady();
}
`CheckReady()` 会遍历必须纹理是否到齐,到了就把状态拉成 `READY`。这个小函数后面还会出问题,先留着。
三、帧边界清理的坑
上篇里我提到,参考项目的帧结束清理依赖手动调 `Reset()`,在某些场景下会被跳过。我在自己代码里试着把这个清理动作绑到 Present 钩子上,思路是每次交换链前面都把上一帧的东西清干净。
void ResourceStateManager::OnPresent() {
std::lock_guard<std::mutex> lock(mtx);
if (state == FrameState::READY ||
state == FrameState::PROCESSING ||
state == FrameState::FRAME_END) {
// 清理上一帧的残留
textures.clear();
state = FrameState::IDLE;
}
}
写完之后用《巫师3》测,正常跑没问题。但一进游戏内菜单再退出来,挂了。原因是《巫师3》的菜单界面进了一个新的渲染路径,这期间没有正常的 Present 调用,但我的 Manager 里还挂着上一帧的纹理引用。等菜单退出、场景恢复渲染时,游戏引擎重新创建了一轮纹理,可我的哈希表里还残留着旧的记录,于是 `CheckReady()` 早早判断满足条件——拿着已经失效的旧纹理指针去提交上采样,崩得渣都不剩。
修法也简单,不只依赖 Present 清理,还要加一个场景切换时的检查:如果连续几帧没收新纹理,就主动清空。这个我还没写得很完善,目前暂时用了一个简单的帧间隔计时器来兜底。后面得专门抽时间搞健壮。
四、反应掩膜缺失——弹性适配的第一次实战
DX12 路径下用 FSR 3.1 后端,需要反应掩膜(Reactive Mask)。这东西在正常游戏视角下基本都有,但有些游戏的过场动画、地图界面就不生成。
我最初的 `CheckReady()` 是这样的:
void ResourceStateManager::CheckReady() {
if (textures.count(TextureSlot::COLOR) == 0) return;
if (textures.count(TextureSlot::DEPTH) == 0) return;
if (textures.count(TextureSlot::MOTION_VECTORS) == 0) return;
if (textures.count(TextureSlot::REACTIVE_MASK) == 0) return; // 这里卡死了
state = FrameState::READY;
}
《巫师3》自由探索时一切正常,但一打开地图界面,`REACTIVE_MASK` 永远不会来,状态永远卡在 `WAITING_RESOURCES`,上采样被无限期跳过。画面倒是没崩,就是画质突然掉回原生低分辨率,观感瞬间变差。
我把反应掩膜标记为“可选资源”,逻辑改成:如果到上采样提交时还没收到,就生成一张 1×1 的默认纹理顶上。
ID3D12Resource* ResourceStateManager::GetOrCreatePlaceholder(TextureSlot slot) {
if (textures.count(slot)) {
return textures[slot].resource;
}
// 只有可选资源才允许用占位纹理
if (slot == TextureSlot::REACTIVE_MASK || slot == TextureSlot::COMPOSITION_MASK) {
if (!placeholderMask) {
Create1x1Texture(DXGI_FORMAT_R8_UNORM, &placeholderMask);
}
return placeholderMask;
}
return nullptr; // 必须资源没到,调用者自己处理
}
`Create1x1Texture` 第一次被需要的时候建一张 1×1 的 R8 纹理,后面一直复用。这玩意儿对画质肯定有影响——反应掩膜全零意味着 FSR 对画面所有区域一视同仁,UI 边缘可能会有轻微晃动——但比直接退回到原生低分辨率强太多了。
这个改动做完,地图界面打开再关闭的过渡顺滑了很多。
五、当前进展和真实感受
到这个周末为止,Resource & State Manager 模块的 DX12 路径已经跑通了几个基础场景。FSR 3.1 和 XeSS 后端都能正常拿到资源,我也在《巫师3》和《地狱之刃》里反复进菜单、切场景去折腾它。目前的状态:
| 做了的事 | 状态 |
|---|---|
| 单例结构 + 锁 | 跑通了,性能还没压测 |
| 帧状态机基本流转 | 大部分情况正常,场景切换的清理还要加强 |
| 纹理哈希追踪 + 重复注册处理 | 暂时能用了,但逻辑写死取大分辨率有点粗暴 |
| 必须 / 可选资源拆分 | 反应掩膜和合成掩膜已改为可选,用了占位纹理 |
| Present 钩子自动清理 | 写好了,但需要配合场景切换兜底 |
| DLSS 输入适配 | 刚开始看接口,还没动手 |
| Vulkan 路径 | 没碰 |
最大的感受:中间件级别的代码,容错设计比功能实现要花更多时间。写功能的时候思路是“拿到 A 就做 B”,很顺畅;写容错的时候思路是“拿不到 A 怎么办、拿到两个 A 怎么办、拿到 A 但 A 是上一帧的怎么办”——脑子要转好几个弯。
另外,DX12 的资源命名和调试工具帮了大忙。PIX 里可以直接看纹理的生命周期和引用关系,没它的话上面那个深度缓冲被覆盖的 bug 我可能得摸好几天。
六、接下来
这周把骨架搭起来了,也踩了几个不致命但能学东西的坑。下面几周打算:
把场景切换的清理策略写完,别再让残留纹理害我崩程序
补上 DLSS 作为输出时的资源映射,把“后端无关输入包”这个抽象层再推进一步
拿更多不同类型游戏测试,尤其是那些渲染管线比较特殊的(比如用延迟渲染做 UI 的)
如果有余力,开始看 Vulkan 的资源追踪跟 DX12 差在哪
写代码的时候一旦发现自己在凭想象做假设,十有八九后面会出问题。还是那句话,跑起来再说话。
