阴影图集槽位换了灯,迟到渲染不能覆盖新所有者
一盏聚光灯离开视野后,它占用的阴影图集槽位很快会分配给另一盏灯。麻烦在于,旧灯的阴影渲染可能仍在 GPU 队列中:槽位索引没有变化,完成顺序却变了。如果回调只携带 slot=0,迟到的深度内容就能覆盖新灯刚获得的区域,接下来一帧会用楼梯的阴影去遮挡另一处立柱。

这个问题不能靠“通常按提交顺序完成”来规避。阴影图集是固定容量资源,槽位复用本来就是正常路径;只要渲染录制、GPU 执行和主线程分配存在不同步,索引便不再足以表达所有权。
槽位索引只说明写到哪里
探针把图集简化为一个槽位。旧所有者是灯光 41 的第 7 版参数,新所有者是灯光 88 的第 3 版参数。ShadowAtlas 唯一持有槽位代次、当前灯光身份、灯光修订和已经提交的 tile;渲染任务只能持有不可变的 ShadowRenderTicket。
$WORK_DIR/.tmp/daily-labs/2026-09-04-1930/Program.cs 中的关键实现如下。Reserve 每次重新分配都推进 Generation,而 Complete 在写活动 tile 之前逐项验证票据。检查顺序有实际意义:槽位已经复用时先返回 StaleGeneration,不会把旧任务误诊断成当前灯光的参数错误。
public readonly record struct ShadowRenderTicket(
int Slot,
int SlotGeneration,
int LightId,
int LightRevision);
public ShadowRenderTicket Reserve(int slot, int lightId, int lightRevision)
{
SlotState state = GetSlot(slot);
state.Generation++;
state.OwnerLightId = lightId;
state.OwnerLightRevision = lightRevision;
return new ShadowRenderTicket(
slot, state.Generation, lightId, lightRevision);
}
public CommitResult Complete(ShadowRenderTicket ticket, int contentStamp)
{
if ((uint)ticket.Slot >= (uint)slots.Length)
return CommitResult.InvalidSlot;
SlotState state = slots[ticket.Slot];
if (ticket.SlotGeneration != state.Generation)
return CommitResult.StaleGeneration;
if (ticket.LightId != state.OwnerLightId)
return CommitResult.WrongLight;
if (ticket.LightRevision != state.OwnerLightRevision)
return CommitResult.StaleLightRevision;
if (contentStamp < 0)
return CommitResult.InvalidContent;
state.ActiveTile = new ShadowTile(
ticket.LightId, ticket.LightRevision, contentStamp);
return CommitResult.Committed;
}
灯光修订不能省略。即使槽位仍归灯光 88,阴影相机的投影矩阵、裁剪面或 caster 集合也可能已经更新;旧参数生成的深度图与当前采样矩阵并不配套。槽位代次回答“这还是同一次租用吗”,灯光修订回答“这还是同一份阴影输入吗”,二者不能互相替代。
旧内容可以保留,但不能冒充新灯的阴影
重新分配槽位时,探针没有立即清空旧 tile,而是让 Resolve(slot, lightId, lightRevision) 再检查已提交内容的身份。因此灯光 88 完成之前得到的是 none,渲染器应走无阴影或其他明确回退,而不是采样灯光 41 的深度。
这一区分避免了两个极端。若预留槽位就销毁旧内容,分配失败会制造不必要的空洞;若只看槽位是否存在,新灯会短暂使用旧投影空间。保留物理字节与允许逻辑采样是两件事,只有匹配当前灯光身份和修订的 tile 才能越过解析边界。

固定样本先提交 slot0:g1/light41:r7/stamp700,再把同一槽位预留为 slot0:g2/light88:r3。此时新灯不可解析;第一代任务随后携带 stamp701 返回,被 StaleGeneration 拒绝,活动字节没有改变。第二代任务携带 stamp803 完成后,新灯才获得可采样内容。
$ dotnet build $WORK_DIR/.tmp/daily-labs/2026-09-04-1930/ShadowAtlasLab.csproj \
-c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
$ dotnet run --project \
$WORK_DIR/.tmp/daily-labs/2026-09-04-1930/ShadowAtlasLab.csproj \
-c Release --no-build
reuse ticket=slot0:g2:light88:r3 before=none
late=StaleGeneration current=Committed after=light88:r3:stamp803
passed=8 failed=0 skipped=0 elapsed_ms=36.615
八项检查还覆盖错误灯光、陈旧灯光修订、非法内容和越界槽位。非法内容在所有身份检查通过后仍不能替换活动 tile;这证明提交是一次状态切换,不是边验证边修改的过程。测试没有测量 GPU 时间,因此这里不声称该合同改善性能,只确认乱序完成不会破坏可采样状态。
探针把 Reserve 与 Complete 放在单线程中,是为了单独验证身份合同,并不意味着真实实现可以无锁并发。如果图集分配发生在主线程、完成记录由渲染线程回收,那么读取当前代次、比较四项票据和替换 ActiveTile 必须位于同一个临界区,或改写成带版本的单写者消息提交。只给 Generation 使用原子自增仍然不够:完成线程可能先读到新代次,随后与分配线程交错读取旧灯光身份,拼出从未存在过的所有者组合。
同样,票据也不能替代资源屏障。Complete 的前提是对应深度写入已经由 fence、RenderGraph 依赖或后端同步原语证明可见;它只决定这份已完成内容还有没有资格进入活动状态。若 GPU 写入尚未结束就先调用 Complete,身份全都匹配,采样者仍可能读到半张旧图。同步解决“字节是否完成”,票据解决“完成的字节是否仍属于当前请求”,这两道门需要同时存在。
票据必须穿过真实渲染边界
在 Unity RenderGraph、Godot RenderingDevice 或自研渲染器中,类型名称会不同,但票据必须从槽位分配一路进入 pass 数据、command buffer 完成记录或 fence 对应的回收记录。若中途只留下裸槽位索引,主线程在完成回调处无法重建当时的灯光身份与参数修订。
当前切片也没有解决图集排布、过滤核、级联选择或缓存淘汰。它只限定一个更靠下的提交条件:固定容量槽位可以反复复用,旧字节也可以暂存,但完成结果必须同时匹配槽位代次、灯光身份和灯光修订,才能替换活动 tile 并对采样者可见。这样,迟到的灯光 41 最多浪费一次已经发出的工作,不会把灯光 88 的阴影空间改写成无法解释的旧画面。