纹理 Mip 流送:驻留层级与采样描述符必须原子提交
开放世界镜头接近地表时,纹理流送器会把较细 mip 异步送入 GPU。危险不在单次上传,而在 CPU 已把可采样下界改成 mip 2,GPU 却只收到 mip 2 和 4:采样器随后可能落到缺失的 mip 3。证据标识 2026-08-07-2000,以下探针把连续链校验、上传票据与描述符更新收敛为一次提交。
// .tmp/daily-labs/2026-08-07-2000/TextureResidency.cs
// MipPayload, ResidencyTicket
public readonly record struct MipPayload(int Level, int Bytes);
public readonly record struct ResidencyTicket(
int Generation,
int TargetMinMip,
int[] Levels,
int UploadBytes);

驻留边界
TextureResidency 独占 PublishedMinMip 与 DescriptorMinLod。前者表示 GPU 已完整驻留的最细层级,后者是采样描述符允许访问的最细层级;两者必须始终相等。探针使用 8 层纹理,初始值均为 5。请求细化到 2 时,准备阶段只持有候选票据,活动状态仍保持 5。
系统边界停在上传完成回调:磁盘读取、解压和 GPU 复制属于准备域,渲染线程只消费已发布状态。两个不变量是连续层级不能缺口,以及失败和陈旧回调不能改变任何活动字段。真实引擎还需把资源句柄和描述符表槽位放进同一批次,但不改变这两个核心约束。
连续链校验
目标从 5 降到 2,必须提供 2、3、4 三层。固定 RGBA8 数据分别为 262,144、65,536、16,384 字节,总计 344,064 字节。先排序再与 Range(2, 3) 比较,使重复、缺失和越界层级都在创建票据前失败;不能接受“已有多少先公开多少”,因为三线性采样会访问相邻层级。
// .tmp/daily-labs/2026-08-07-2000/TextureResidency.cs
// TextureResidency.Prepare
public ResidencyTicket Prepare(
int targetMinMip,
IEnumerable<MipPayload> payloads)
{
if (targetMinMip < 0 || targetMinMip >= PublishedMinMip)
throw new ArgumentOutOfRangeException(nameof(targetMinMip));
var ordered = payloads.OrderBy(item => item.Level).ToArray();
var expected = Enumerable.Range(
targetMinMip, PublishedMinMip - targetMinMip).ToArray();
if (!ordered.Select(item => item.Level).SequenceEqual(expected))
throw new InvalidOperationException("Mip chain is incomplete");
if (ordered.Any(item => item.Bytes <= 0 || item.Level >= _mipCount))
throw new InvalidOperationException("Mip payload is invalid");
_pending = new ResidencyTicket(
++_generation, targetMinMip, expected,
ordered.Sum(item => item.Bytes));
return _pending.Value;
}
原子提交
正常时序是 Prepare → GPU copy → CompleteUpload → draw。完成事件必须同时更新驻留下界和描述符 LOD,然后清除 pending;若先放宽描述符,当前帧可采样未完成数据,若只更新驻留表,纹理虽已存在却继续以粗层显示。把两字段封装在同一状态所有者内,比跨系统事件最终一致更容易审计。
// .tmp/daily-labs/2026-08-07-2000/TextureResidency.cs
// TextureResidency.CompleteUpload
public bool CompleteUpload(int generation, bool succeeded)
{
if (_pending is not { } pending || pending.Generation != generation)
return false;
if (!succeeded)
{
_pending = null;
return false;
}
PublishedMinMip = pending.TargetMinMip;
DescriptorMinLod = pending.TargetMinMip;
_pending = null;
return true;
}

失败退化
缺少 mip 3 时,Prepare 在生成票据前抛错,活动值仍为 5。上传失败会清除当前 pending 并保留旧描述符;新请求覆盖旧 pending 后,旧代际完成返回 false。恢复路径是重新请求完整连续链,不是把缺层临时指向邻层,因为后者把资源不完整性隐藏进采样语义,难以从画面回推根因。
// .tmp/daily-labs/2026-08-07-2000/Program.cs
// stale-completion-cannot-replace-current-request
Run("stale-completion-cannot-replace-current-request", () =>
{
var state = NewState();
var stale = state.Prepare(2, FullChain());
var current = state.Prepare(
3, FullChain().Where(item => item.Level >= 3));
False(state.CompleteUpload(stale.Generation, true));
Equal(5, state.PublishedMinMip);
True(state.CompleteUpload(current.Generation, true));
Equal(3, state.PublishedMinMip);
Equal(3, state.DescriptorMinLod);
});
运行证据
探针使用 .NET 9 Release 且无外部包。构建和运行退出码均为 0,3 项通过、0 失败、0 跳过。耗时只证明进程内校验与状态转换,不代表磁盘、PCIe 或 GPU 上传性能。
$ cd $WORK_DIR/.tmp/daily-labs/2026-08-07-2000
$ dotnet build TextureResidencyProbe.csproj \
-c Release --nologo --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:03.98
exit=0
$ dotnet run --project TextureResidencyProbe.csproj \
-c Release --no-build
PASS complete-chain-commits-residency-and-descriptor
PASS missing-level-keeps-published-state
PASS stale-completion-cannot-replace-current-request
tests: passed=3 failed=0 skipped=0 elapsedMs=28.5184
before: minMip=5 descriptorMinLod=5 pendingGeneration=1
after: minMip=2 descriptorMinLod=2 pending=null
upload: levels=[2,3,4] bytes=344064
exit=0
工程边界
单纹理单 pending 适合请求可合并的流送器,代价是新目标会覆盖尚未完成的旧目标;这能抑制镜头快速移动时的过期细化,却可能浪费已经启动的传输。并行上传数、陈旧完成数、连续链拒绝数和驻留字节应分别观测。陈旧率持续升高说明请求合并窗口太短,驻留预算长期触顶则应引入全局纹理优先队列,而不是让每张纹理自行扩容。
当系统扩展到稀疏纹理或虚拟纹理时,提交单位应从完整 mip 改为页表事务,代际也应进入页表版本;但描述符可见范围不能领先真实驻留的原则不变。架构判断是:纹理清晰度不是一个可提前修改的整数,而是连续数据、资源状态与采样契约共同组成的版本化快照。