渲染目标热切换:首次清屏完成前不能替换活动视口

动态分辨率、窗口缩放或相机堆栈重建都会要求替换渲染目标。危险不在分配 API,而在两个事实可能分帧完成:新纹理已经有句柄,但 GPU 尚未完成首次清屏。若此时先改活动句柄或视口,下一次采样就可能读到未定义像素,后处理也会按新尺寸解释旧资源。证据标识 2026-08-06-1930,以下探针把一次切换压缩为可复算事务。

// .tmp/daily-labs/2026-08-06-1930/RenderTargetResizeGate.cs
// TargetSpec, TargetHandle, ResizeTicket
public readonly record struct TargetSpec(int Width, int Height, string Format)
{
    public long Bytes => (long)Width * Height * 4;
}

public readonly record struct TargetHandle(
    int Id,
    int Generation,
    TargetSpec Spec);

public readonly record struct ResizeTicket(
    int Generation,
    TargetHandle Candidate,
    long RequiredBytes);

渲染目标尺寸切换的双资源主视觉

提交边界

本文把活动渲染目标定义为渲染器当前可安全读取的句柄、尺寸与格式组合;候选目标只是分配完成但尚未发布的资源。状态由 RenderTargetResizeGate 独占,分配器只能返回候选,GPU 完成回调只能提交匹配代际。两个不变量是:活动句柄与视口必须来自同一规格;候选首次清屏完成前,活动组合不得变化。

// .tmp/daily-labs/2026-08-06-1930/RenderTargetResizeGate.cs
// RenderTargetResizeGate.BeginResize
public ResizeTicket BeginResize(TargetSpec requested, long budgetBytes)
{
    Validate(requested);
    if (requested.Bytes > budgetBytes)
        throw new InvalidOperationException(
            "render-target-budget-exceeded");

    var generation = ++_generation;
    var candidate = new TargetHandle(
        _nextId++, generation, requested);
    _pending = new ResizeTicket(
        generation, candidate, requested.Bytes);
    return _pending.Value;
}

预算在分配前计算。RGBA8 的 1920×1080 候选占 1920 × 1080 × 4 = 8,294,400 字节;这只是单颜色附件,不包含深度、MSAA 与历史缓冲。因此该值用于验证提交语义,不能外推为完整渲染管线显存。

状态所有权

正常时序是 BeginResize → allocate → clear → CompleteClear → renderer。句柄与视口只有最后一步一起改变。把视口提前写到窗口系统看似能减少一次状态保存,实际会让渲染器在候选仍为 pending 时使用 1920×1080 视口写入 1280×720 活动附件,直接破坏边界一致性。

// .tmp/daily-labs/2026-08-06-1930/RenderTargetResizeGate.cs
// RenderTargetResizeGate.CompleteClear
public bool CompleteClear(int generation, bool clearSucceeded)
{
    if (_pending is not { } pending ||
        pending.Generation != generation)
        return false;

    if (!clearSucceeded)
    {
        _pending = null;
        return false;
    }

    Active = pending.Candidate;
    Viewport = pending.Candidate.Spec;
    _pending = null;
    return true;
}

渲染目标分配、清屏与原子提交时序

正常提交

固定输入从 1280×720 RGBA8 切到 1920×1080。测试先断言候选建立后活动句柄仍为 1、视口仍为旧尺寸,再完成第 1 代清屏,最后同时核对候选句柄和规格。这里不把“分配成功”当成“可读”,首次清屏才是发布屏障。

// .tmp/daily-labs/2026-08-06-1930/Program.cs
// clear-completion-commits-handle-and-viewport
Run("clear-completion-commits-handle-and-viewport", () =>
{
    var gate = NewGate();
    var ticket = gate.BeginResize(
        new(1920, 1080, "RGBA8"), 16_000_000);

    Equal(1, gate.Active.Id);
    Equal(new TargetSpec(1280, 720, "RGBA8"),
        gate.Viewport);

    True(gate.CompleteClear(ticket.Generation, true));
    Equal(ticket.Candidate, gate.Active);
    Equal(ticket.Candidate.Spec, gate.Viewport);
});

失败退化

失败时序有两类。清屏失败意味着候选内容不可定义,门禁丢弃 pending 并保留旧目标;旧代际回调意味着更晚的尺寸请求已覆盖它,门禁返回 false,不能让先到的旧 GPU 完成事件反向提交。恢复不是回滚活动资源,因为活动资源从未被改动;调用方可以降低尺寸或预算后重新发起新代际。

// .tmp/daily-labs/2026-08-06-1930/Program.cs
// failed-clear-keeps-active-target
Run("failed-clear-keeps-active-target", () =>
{
    var gate = NewGate();
    var original = gate.Active;
    var ticket = gate.BeginResize(
        new(1920, 1080, "RGBA8"), 16_000_000);

    False(gate.CompleteClear(ticket.Generation, false));
    Equal(original, gate.Active);
    Equal(new TargetSpec(1280, 720, "RGBA8"),
        gate.Viewport);
    Equal(null, gate.Pending);
});

单个 pending 槽位的取舍是简单且确定:连续缩放只保留最新请求,旧候选由外层资源回收器延迟释放。它不适合同时准备多相机、多附件集合;一旦切换对象扩展为颜色、深度、速度和历史纹理,应把 TargetHandle 提升为附件包,并仍以一个代际提交包内全部成员。

运行证据

命令在 Release 配置执行,无外部包。构建退出码与运行退出码均为 0,3 项通过、0 失败、0 跳过。固定输出显示清屏前活动代际为 0、视口为 1280×720,pending 为 1;清屏后活动代际变为 1、视口变为 1920×1080,pending 清空。

$ cd $WORK_DIR/.tmp/daily-labs/2026-08-06-1930
$ dotnet build RenderTargetResizeProbe.csproj -c Release --nologo
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:07.67
exit=0

$ dotnet run --project RenderTargetResizeProbe.csproj \
    -c Release --no-build
PASS clear-completion-commits-handle-and-viewport
PASS failed-clear-keeps-active-target
PASS stale-clear-cannot-commit-newer-candidate
tests: passed=3 failed=0 skipped=0 elapsedMs=32.8287
before: activeId=1 generation=0 viewport=1280x720 pending=1
after:  activeId=2 generation=1 viewport=1920x1080 pending=null
requiredBytes=8294400
exit=0

这组测试验证的是状态事务,不是 GPU 驱动性能。32.8287 毫秒包含进程内断言与序列化,不代表清屏耗时,也不能用于比较图形 API。性能判断需要接入真实命令队列时间戳和多帧样本,本章不作该外推。

工程边界

当前方案适用于同一渲染线程拥有活动目标、GPU 完成事件异步返回的单提交点。容量触发条件不是固定分辨率,而是附件总字节超过预算;并发触发条件是出现多个独立相机或多个在途候选。前者应在 BeginResize 前计算完整附件包,后者应引入按相机分区的门禁与统一延迟释放队列。

架构判断是:运行时尺寸切换不应暴露“已分配”布尔值,而应暴露带代际的候选票据,并把首次清屏作为可见性屏障。只要句柄、视口和附件规格仍由同一提交点发布,失败就退化为继续使用旧帧资源,而不是把未定义像素扩散到后处理链。