动态分辨率切换不能只换颜色纹理:颜色与深度附件必须成对提交
动态分辨率把渲染尺寸从 1920×1080 降到 1280×720 时,颜色纹理先创建成功,于是渲染器立刻把它换进活动状态;深度纹理随后分配失败,下一帧却仍按旧的 1920×1080 深度附件工作。此时问题并非画质暂时下降,而是活动帧缓冲已经失去完整定义:颜色与深度的像素坐标不再描述同一个采样格。
把两次资源赋值写在相邻两行,不能提供原子性。真正需要提交的是一个同时包含颜色、深度、尺寸和修订号的渲染目标包。新包通过完整性检查前,旧包继续拥有可见输出。

活动对象应只有一个可观察身份
研究探针把状态压缩为 RenderTargetBundle。Extent 不从外部缓存读取,而由附件本身给出;IsComplete 则直接检查两张附件是否同尺寸。这样调用方不能在颜色已经更新、深度仍旧时读到一个“看起来已经切换”的宽高。
readonly record struct Extent(int Width, int Height)
{
public bool IsValid => Width > 0 && Height > 0;
}
sealed class RenderTargetBundle : IDisposable
{
public RenderTargetBundle(int revision, Attachment color, Attachment depth)
{
Revision = revision;
Color = color;
Depth = depth;
}
public int Revision { get; }
public Attachment Color { get; }
public Attachment Depth { get; }
public Extent Extent => Color.Extent;
public bool IsComplete => Color.Extent == Depth.Extent;
public void Dispose()
{
Color.Dispose();
Depth.Dispose();
}
}
这里的修订号解决的是异步重建的顺序问题。若修订 7 的 960×540 资源先完成,迟到的修订 6 就不能把活动状态倒退回旧请求,即使它的两张附件本身完整。尺寸检查与修订检查不能互相替代:前者证明包内一致,后者证明包仍对应当前意图。
分配完成只是候选成立的前提
入口符号 DynamicRenderTarget.TryResize 只在所有条件通过后写一次 Active。颜色分配失败时没有候选;深度分配失败时立即释放已经创建的颜色附件;尺寸不一致或修订陈旧时释放整个候选包。旧包仅在交换完成后才被回收。
public ResizeResult TryResize(
int revision,
Extent extent,
IAttachmentAllocator allocator)
{
if (!extent.IsValid)
return ResizeResult.InvalidExtent;
var color = allocator.AllocateColor(extent);
if (color is null)
return ResizeResult.ColorAllocationFailed;
var depth = allocator.AllocateDepth(extent);
if (depth is null)
{
color.Dispose();
return ResizeResult.DepthAllocationFailed;
}
var candidate = new RenderTargetBundle(revision, color, depth);
if (!candidate.IsComplete)
{
candidate.Dispose();
return ResizeResult.IncompleteBundle;
}
if (revision <= Active.Revision)
{
candidate.Dispose();
return ResizeResult.StaleRevision;
}
var previous = Active;
Active = candidate;
previous.Dispose();
return ResizeResult.Committed;
}
提交顺序中的最后三行很重要。先释放旧包再写入新包,会制造一个没有活动目标的窗口;先逐字段改写活动包,又会让读取方看到混合修订。交换引用后再释放旧包,才让读取单位始终是一份完整快照。在多线程渲染器中,这个交换点还需要落到渲染线程或适当的同步原语上;当前探针验证的是提交协议,不声称覆盖跨线程内存可见性。

错误尺寸必须在绑定前成为可测试结果
测试符号 MismatchedExtentPreservesActive 故意让颜色候选为 960×540、深度候选为 960×536。若把完整性检查推迟到图形 API 的绑定阶段,失败会以平台相关错误或残缺画面出现;在资源所有者边界提前检查,调用者会得到稳定的 IncompleteBundle,两张候选附件也有明确的释放路径。
同一组测试还覆盖深度分配失败、颜色分配失败、零宽尺寸、陈旧修订、成功交换后释放旧包,以及拒绝候选不泄漏资源。执行命令与决定性输出如下:
$ cd $WORK_DIR/.tmp/daily-labs/render-target-bundle
$ dotnet build RenderTargetBundleLab.csproj -c Release -p:NuGetAudit=false
Build succeeded.
0 Warning(s)
0 Error(s)
exit=0
$ dotnet run --project RenderTargetBundleLab.csproj -c Release --no-build --no-restore
normal result=Committed active_revision=5 extent=1280x720 complete=True
depth_failure result=DepthAllocationFailed active_revision=5 extent=1280x720 candidate_color_disposed=True
mismatch result=IncompleteBundle active_revision=5 color=960x540 depth=960x536
tests pass=8 fail=0 skip=0 duration_ms=42.29
exit=0
这些数据证明的是状态与资源生命周期,不是 GPU 性能。探针没有创建真实图形设备,也没有测量显存分配延迟,因此不能据此声称动态分辨率切换更快。接入 Unity、Godot、Three.js 或自研渲染器时,分配器应替换为对应的 RenderTexture、纹理/RID 或 WebGL 资源创建逻辑,完整性门禁仍留在拥有活动目标的宿主一侧。
哪些附件必须进入同一个包
本文只放入颜色和深度,是为了把纵向切片保持到可验证的最小范围。实际管线若还会同步替换速度、法线、历史颜色、曝光缓冲或分辨率相关常量,它们同样可能属于提交单位。判断依据不是“都由渲染器使用”,而是新旧资源混合后是否仍能描述同一帧、同一尺寸和同一历史。
当附件数量增加,逐项布尔判断会变得脆弱。可以把候选构建改为返回完整描述符和验证报告,但不应让每张纹理各自拥有可见提交权。只有在所有附件验证通过后交换一次活动包,失败时整包释放并保留旧输出,这个边界才能同时约束动态分辨率、窗口缩放与渲染管线重建。
开头的 1920×1080 到 1280×720 切换因此有了明确结果:成功时颜色和深度一起进入修订 5;任何后续分配失败、尺寸错配或迟到完成都不会拆开这对附件。当前方案没有处理设备丢失、跨线程栅栏和历史缓冲重投影,它适用于资源可独立构建、活动目标可通过单一所有者交换的渲染架构。