虚拟纹理物理页还没上传完,页表不能先改映射

地形相机向前移动时,请求系统已经为更清晰的 Mip 分配了物理缓存页,页表也立刻改好了映射,但画面却短暂出现一块旧材质。问题不一定在采样器或 LOD 计算:如果页表先指向物理页,纹理上传还在队列中,Shader 看到的“新映射”实际上连到一块尚未完成写入的存储。

虚拟纹理有两种不同的完成。物理页分配完成,只说明缓存槽位有了候选身份;GPU 上传完成,才说明该身份下的数据可以被采样。把两者压成一个 resident=true,页表更新就失去了可验证的提交点。

虚拟纹理大页空间与有限物理缓存之间的页面传输

等待上传时,旧映射仍然是正确答案

固定样本中的虚拟页 37 当前映射为 v37/m3 -> p2:g4@r7。请求 8 希望把它提升到 Mip 2,并将内容写入物理槽 5、代次 3。QueueUpload 只登记候选和最新请求号,不触碰活动页表;CompleteUpload 同时检查上传结果、物理槽代次和请求新旧,全部通过后才替换一条完整的 PageTableEntry

$LAB_DIR/Program.cs 中的状态所有者保留了完整提交路径:

sealed class VirtualTextureResidency
{
    private readonly Dictionary<int, int> _slotGenerations = new();
    private readonly Dictionary<int, int> _latestRequests = new();
    private readonly Dictionary<int, PendingUpload> _pending = new();
    private readonly Dictionary<int, PageTableEntry> _active = new();

    public IReadOnlyDictionary<int, PageTableEntry> Active => _active;

    public CommitResult QueueUpload(int requestId, VirtualPage page, PhysicalPage physical)
    {
        if (page.Id < 0 || page.Mip < 0)
            return CommitResult.InvalidPage;
        if (!_slotGenerations.TryGetValue(physical.Slot, out var generation) ||
            generation != physical.Generation)
            return CommitResult.InvalidPhysicalPage;
        if (_latestRequests.TryGetValue(page.Id, out var latest) && requestId <= latest)
            return CommitResult.StaleRequest;

        _latestRequests[page.Id] = requestId;
        _pending[requestId] = new PendingUpload(page, physical, requestId);
        return CommitResult.UploadQueued;
    }

    public CommitResult CompleteUpload(int requestId, bool succeeded, uint contentHash)
    {
        if (!_pending.Remove(requestId, out var upload))
            return CommitResult.MissingUpload;
        if (!succeeded)
            return CommitResult.UploadFailed;
        if (!_slotGenerations.TryGetValue(upload.Physical.Slot, out var generation) ||
            generation != upload.Physical.Generation)
            return CommitResult.ReusedPhysicalPage;
        if (_latestRequests[upload.Virtual.Id] != requestId)
            return CommitResult.StaleRequest;

        _active[upload.Virtual.Id] = new PageTableEntry(
            upload.Virtual, upload.Physical, contentHash, requestId);
        return CommitResult.Committed;
    }
}

上传待定期间,页 37 继续采样 Mip 3。它可能比目标层级模糊,却仍是一份完整、身份明确的数据。请求 8 成功后,映射一次性变为 v37/m2 -> p5:g3@r8。这里的原子性不是指 CPU 字典赋值能代表真实 GPU 同步,而是规定 GPU 可见的页表更新必须排在对应上传完成屏障之后;实际渲染器需要用自己的 fence、队列所有权转移和命令缓冲顺序实现这条合同。

页 37 的上传、提交与物理槽复用边界

槽位编号相同,不代表物理页还是同一份资源

有限物理缓存必然复用槽位。探针为每个槽记录单调递增的代次:请求 9 排队时持有 p7:g11,上传完成前槽 7 被回收并变为 p7:g12。完成事件即使仍写着槽 7,也只能返回 ReusedPhysicalPage,不能为虚拟页 61 发布映射。

只比较槽位编号会把两个生命周期错误地当成同一个物理页。更隐蔽的失败来自同一虚拟页的请求乱序:请求 8 尚未完成,请求 9 已经成为最新需求,此时请求 8 的成功回调仍应返回 StaleRequest。否则相机已经要求 Mip 1,迟到的 Mip 2 反而可能覆盖更新的页表决定。

RecycleSlot 还会删除仍指向该槽的活动项。生产实现可以先切回父级 Mip 或缺页标记,再回收物理页,但不能让页表继续指向已重新分配的存储。这里没有强行规定退化画面长什么样,只规定旧身份不能继续可见。

一次运行能证明提交顺序,不能替代 GPU 屏障

验证命令与决定性输出如下:

cd "$LAB_DIR"
dotnet restore VirtualTextureCommitLab.csproj --ignore-failed-sources -p:NuGetAudit=false
dotnet build VirtualTextureCommitLab.csproj -c Release --no-restore
dotnet run --project VirtualTextureCommitLab.csproj -c Release --no-build
Build succeeded. 0 Warning(s) 0 Error(s)
normal queue=UploadQueued pending_mapping=v37/m3->p2:g4@r7 complete=Committed active_mapping=v37/m2->p5:g3@r8
boundary queue=UploadQueued recycled_slot=7:12 complete=ReusedPhysicalPage page61_mapped=False page37=v37/m2->p5:g3@r8
tests pass=8 fail=0 skip=0 duration_ms=44.72

离线恢复、Release 构建和运行退出码均为 0,墙钟耗时分别为 2.29 秒、6.82 秒和 1.84 秒。八项检查覆盖待定映射、成功提交、上传失败、槽位复用、同页新请求、重复请求、缺失上传与非法页号。这些时间只证明验证实际执行,不是流送吞吐或 GPU 延迟数据。

探针使用字典保存活动映射,单次查找和替换的平均复杂度为 O(1);回收槽位时为便于观察而扫描全部活动页,复杂度为 O(R)R 是当前驻留页数。生产系统应维护槽位到虚拟页的反向索引,避免回收成本随驻留集增长。

若页表采用双缓冲,提交单位还应是“下一份页表中的完整更新批次”,而不是让每个上传回调直接写当前采样缓冲。请求号应按虚拟页维护,因为不同页面的上传可以并行;物理槽代次则按槽维护,因为复用发生在缓存存储这一侧。内容哈希在探针中用于显示候选数据身份,不承担同步职责,也不能替代 fence。三种标识分别回答“这是不是最新需求”“这还是不是原来的存储”“写进去的是什么”,混用任何两个都会留下无法判定的迟到结果。

进一步扩展到多队列上传时,还需要明确 GPU fence 所属队列、页表缓冲交换点和一帧内可提交的更新批次;本文没有用 CPU 探针冒充这些尚未验证的结论。可观测性也应区分 UploadFailedStaleRequestReusedPhysicalPage:前者指向数据或传输失败,后两者是调度与缓存压力信号。只有分类计数,团队才能判断应该修资源、调预算,还是检查队列时序。

开头那块旧材质的根因不是“上传慢”,而是页表把未完成状态公开给了采样端。保留旧映射,直到上传成功、请求仍最新、槽位代次仍匹配,画面退化最多是暂时使用较低 Mip,而不会采样未知内容。物理缓存怎样淘汰可以继续演进,但上传完成与映射发布必须是两个阶段,且只有后者能改变 GPU 可见身份。