着色器变体预热:帧预算不能提前发布材质就绪

战斗场景首次出现水面、蒙皮角色和体积雾时,运行时编译变体可能把一次材料切换拆成多个不可预测的停顿。把所有变体塞进单帧会扩大尖峰;逐个编译后立即声明材质可用,又会让同一场景混用正式材质与回退材质。证据标识 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/3WaterSurface/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,CharacterLitWorldFog 成功而 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;

固定 9 单位目录的执行、失败与重试就绪屏障

回归证据

三项回归分别锁定跨帧整体就绪、失败后保留回退和旧目录拒绝。固定目录成本为 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)。当目录达到数千项、编译可并行、或多个场景同时预热时,应把数组扫描升级为稳定优先队列,并将编译完成写入线程安全收集器;但最终发布仍必须回到单一所有者核对目录代际、失败集合和全量计数。若成本权重无法预测真实尖峰,应采集驱动侧耗时后调整预算模型,而不是放宽整体就绪屏障。

结论是:帧预算决定预热何时推进,目录屏障决定材质何时可见,两者不能共用“队列有进展”这一状态。场景加载器只接收完整目录的就绪位,既避免单帧集中编译,也阻止部分成功、失败重试和陈旧回调把不一致材质带入正式画面。