着色器变体预热:帧预算不能提前发布材质就绪
战斗场景首次出现水面、蒙皮角色和体积雾时,运行时编译变体可能把一次材料切换拆成多个不可预测的停顿。把所有变体塞进单帧会扩大尖峰;逐个编译后立即声明材质可用,又会让同一场景混用正式材质与回退材质。证据标识 2026-08-05-2000 的 C# 探针把预热定义为受预算约束、但只能整体发布就绪的目录事务。
// .tmp/daily-labs/2026-08-05-2000/ShaderWarmupGate.cs
// VariantKey / WarmupItem / WarmupTicket / WarmupStep
public readonly record struct VariantKey(string Shader, string Keywords);
public readonly record struct WarmupItem(VariantKey Key, int CostUnits);
public readonly record struct WarmupTicket(int CatalogGeneration);
public readonly record struct WarmupStep(
int UsedUnits,
int WarmedCount,
VariantKey[] Failed,
bool Ready);
所有权边界
ShaderWarmupGate 唯一拥有当前目录代际、已完成集合、失败集合和公开的 Ready。TA 导出的目录只描述必需变体,编译后端只返回成败,场景加载器只读取就绪位;实际驱动缓存与 GPU 管线创建不在探针范围内。两个不变量是:预算不足或任一编译失败时仍使用完整回退路径;旧目录票据永远不能发布新目录的就绪状态。

目录代际
新目录先拒绝空集合、非正成本和重复键,再清空旧进度并递增代际。CostUnits 是固定输入中的调度权重,不是毫秒,也不支持性能结论。票据绑定目录而非单个 Shader,因为关键词组合才是编译身份;只校验 Shader 名会把同一 Shader 的不同关键字错误合并。
// .tmp/daily-labs/2026-08-05-2000/ShaderWarmupGate.cs
// ShaderWarmupGate.BeginCatalog
public WarmupTicket BeginCatalog(params WarmupItem[] items)
{
if (items.Length == 0 || items.Any(item => item.CostUnits <= 0))
throw new ArgumentException("Catalog items require positive cost.");
if (items.Select(item => item.Key).Distinct().Count() != items.Length)
throw new ArgumentException("Catalog contains duplicate variants.");
catalog = [.. items];
warmed.Clear();
failed.Clear();
Ready = false;
return new WarmupTicket(++generation);
}
预算推进
正常时序以 5 单位处理 CharacterLit/3 与 WaterSurface/2,首帧仍不发布;下一帧用 4 单位完成 WorldFog 后,三项齐备才把 Ready 置为 true。循环按目录顺序执行,遇到首个超预算项即停止,因此目录顺序也是确定性调度合同。跳过大项继续处理后项虽能提高本帧利用率,却会让加载次序随成本分布变化,削弱复现能力。
// .tmp/daily-labs/2026-08-05-2000/ShaderWarmupGate.cs
// ShaderWarmupGate.ExecuteFrame
public WarmupStep ExecuteFrame(
WarmupTicket ticket,
int budgetUnits,
Func<VariantKey, bool> compile)
{
if (!Owns(ticket) || budgetUnits < 0)
return Snapshot(0);
var used = 0;
foreach (var item in catalog)
{
if (warmed.Contains(item.Key) || failed.Contains(item.Key))
continue;
if (used + item.CostUnits > budgetUnits)
break;
used += item.CostUnits;
if (compile(item.Key)) warmed.Add(item.Key);
else failed.Add(item.Key);
}
PublishReadiness();
return Snapshot(used);
}
失败屏障
失败时序把整帧预算设为 9,CharacterLit 与 WorldFog 成功而 WaterSurface 失败。失败项进入独立集合,后续帧不会无界重复编译;场景继续使用回退路径。显式 Retry 必须同时命中当前代际和失败键,成功后重新核对完整目录。直接把 Ready 定义为“至少一个变体成功”会制造材质撕裂;定义为“队列为空”则会把失败项误判为完成。
// .tmp/daily-labs/2026-08-05-2000/ShaderWarmupGate.cs
// ShaderWarmupGate.Retry / PublishReadiness
public bool Retry(
WarmupTicket ticket,
VariantKey key,
Func<VariantKey, bool> compile)
{
if (!Owns(ticket) || !failed.Remove(key)) return false;
if (compile(key)) warmed.Add(key);
else failed.Add(key);
PublishReadiness();
return warmed.Contains(key);
}
private bool Owns(WarmupTicket ticket) =>
ticket.CatalogGeneration == generation;
private void PublishReadiness() =>
Ready = warmed.Count == catalog.Length && failed.Count == 0;

回归证据
三项回归分别锁定跨帧整体就绪、失败后保留回退和旧目录拒绝。固定目录成本为 3 + 2 + 4 = 9;失败帧实际消耗 9 单位、完成 2 项、失败 1 项且 Ready=false,重试后才变为 true。该数据证明状态转换,不代表真实驱动编译耗时。
// .tmp/daily-labs/2026-08-05-2000/Program.cs
// failed_variant_keeps_fallback_until_retry
Run("failed_variant_keeps_fallback_until_retry", () =>
{
var (gate, ticket, _, water, _) = Fixture();
var step = gate.ExecuteFrame(ticket, 9, key => key != water);
Equal((9, 2, 1, false),
(step.UsedUnits, step.WarmedCount, step.Failed.Length, step.Ready));
Equal(true, gate.Retry(ticket, water, _ => true));
Equal(true, gate.Ready);
});
Run("stale_catalog_cannot_publish_readiness", () =>
{
var (gate, oldTicket, skin, water, fog) = Fixture();
var current = gate.BeginCatalog(
new WarmupItem(skin, 3), new WarmupItem(water, 2),
new WarmupItem(fog, 4));
var stale = gate.ExecuteFrame(oldTicket, 9, _ => true);
Equal((0, 0, false), (stale.UsedUnits, stale.WarmedCount, stale.Ready));
Equal(true, gate.ExecuteFrame(current, 9, _ => true).Ready);
});
$ dotnet build $WORK_DIR/ShaderWarmupProbe.csproj -c Release --nologo
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:07.01
Exit code: 0
$ dotnet run --project $WORK_DIR/ShaderWarmupProbe.csproj -c Release --no-build
PASS budgeted_catalog_becomes_ready_together
PASS failed_variant_keeps_fallback_until_retry
PASS stale_catalog_cannot_publish_readiness
{"evidence":"2026-08-05-2000","budgetUnits":9,
"failedFrame":{"UsedUnits":9,"WarmedCount":2,
"Failed":["WaterSurface"],"Ready":false},"retryReady":true}
tests=3 passed=3 failed=0 skipped=0 elapsed_ms=41
Exit code: 0
演进条件
当前实现适合一次只激活一个目录、变体数量可由内存集合完整容纳的加载边界,时间复杂度为每帧扫描 O(n)。当目录达到数千项、编译可并行、或多个场景同时预热时,应把数组扫描升级为稳定优先队列,并将编译完成写入线程安全收集器;但最终发布仍必须回到单一所有者核对目录代际、失败集合和全量计数。若成本权重无法预测真实尖峰,应采集驱动侧耗时后调整预算模型,而不是放宽整体就绪屏障。
结论是:帧预算决定预热何时推进,目录屏障决定材质何时可见,两者不能共用“队列有进展”这一状态。场景加载器只接收完整目录的就绪位,既避免单帧集中编译,也阻止部分成功、失败重试和陈旧回调把不一致材质带入正式画面。