渲染目标热切换:首次清屏完成前不能替换活动视口
动态分辨率、窗口缩放或相机堆栈重建都会要求替换渲染目标。危险不在分配 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 前计算完整附件包,后者应引入按相机分区的门禁与统一延迟释放队列。
架构判断是:运行时尺寸切换不应暴露“已分配”布尔值,而应暴露带代际的候选票据,并把首次清屏作为可见性屏障。只要句柄、视口和附件规格仍由同一提交点发布,失败就退化为继续使用旧帧资源,而不是把未定义像素扩散到后处理链。