动态分辨率切换不能只换颜色纹理:颜色与深度附件必须成对提交

动态分辨率把渲染尺寸从 1920×1080 降到 1280×720 时,颜色纹理先创建成功,于是渲染器立刻把它换进活动状态;深度纹理随后分配失败,下一帧却仍按旧的 1920×1080 深度附件工作。此时问题并非画质暂时下降,而是活动帧缓冲已经失去完整定义:颜色与深度的像素坐标不再描述同一个采样格。

把两次资源赋值写在相邻两行,不能提供原子性。真正需要提交的是一个同时包含颜色、深度、尺寸和修订号的渲染目标包。新包通过完整性检查前,旧包继续拥有可见输出。

成对的颜色与深度附件穿过统一提交边界

活动对象应只有一个可观察身份

研究探针把状态压缩为 RenderTargetBundleExtent 不从外部缓存读取,而由附件本身给出;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;任何后续分配失败、尺寸错配或迟到完成都不会拆开这对附件。当前方案没有处理设备丢失、跨线程栅栏和历史缓冲重投影,它适用于资源可独立构建、活动目标可通过单一所有者交换的渲染架构。