Shader 变体缓存:规范键与源码修订共同决定失效

材质系统常把关键字数组直接拼成 Shader 变体键。同一组关键字若顺序不同,会重复编译;源码热重载后若键不含修订号,又会继续命中旧二进制。2026-07-31-2000 探针把语义维度和版本维度放入一个不可变键,缓存命中与失效由同一合同判断。

// .tmp/daily-labs/2026-07-31-2000/Program.cs
readonly record struct VariantKey(
    string Shader,
    string Pass,
    string Keywords,
    int SourceRevision)
{
    public static VariantKey Create(
        string shader,
        string pass,
        IEnumerable<string> keywords,
        int sourceRevision) =>
        new(shader, pass,
            string.Join(';', keywords.Distinct(StringComparer.Ordinal)
                .Order(StringComparer.Ordinal)),
            sourceRevision);
}

Shader 变体主视觉

键的边界

缓存状态由 VariantCache 拥有,材质只提交 Shader、Pass、关键字集合与源码修订,编译器只在未命中时执行。两个不变量是:关键字排列和重复项不改变键;Shader 或 Pass 不同绝不能共享产物。使用序号比较和分号连接,使键不依赖当前语言区域。

关键字在进入键之前还应由上游校验合法集合;本探针只负责规范化,不静默删除未知项。静默删除会把配置错误伪装成缓存命中,使材质退化难以从摘要反查。

// Program.cs: VariantKey.Create
public static VariantKey Create(
    string shader,
    string pass,
    IEnumerable<string> keywords,
    int sourceRevision) =>
    new(
        shader,
        pass,
        string.Join(';', keywords
            .Distinct(StringComparer.Ordinal)
            .Order(StringComparer.Ordinal)),
        sourceRevision);

摘要用途

探针以 SHA-256 的前 12 个十六进制字符作为日志摘要,而字典仍使用完整结构键。摘要只用于观察和图示,不承担唯一性;若把截断摘要直接当缓存主键,碰撞将把不同变体错误地视为相同。固定键的摘要为 2a876a0d5092,可以由四个字段重算。

// Program.cs: VariantKey.Digest
public string Digest()
{
    var source = $"{Shader}|{Pass}|{Keywords}|{SourceRevision}";
    var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source));
    return Convert.ToHexString(bytes)
        [..12]
        .ToLowerInvariant();
}

Shader 缓存键证据图

命中时序

正常时序是材质收集关键字、去重排序、构造结构键、查询缓存、缺失时编译并存储。固定输入先以 FOG,SKIN 请求,再以 SKIN,FOG 请求;两者得到同一键,因此请求 2 次只编译 1 次。编译函数由缓存边界调用,材质侧不能在查询前自行编译。

// Program.cs: VariantCache.GetOrCompile
public string GetOrCompile(
    VariantKey key,
    Func<VariantKey, string> compiler)
{
    if (compiled.TryGetValue(key, out var value))
        return value;
    value = compiler(key);
    compiled.Add(key, value);
    return value;
}

失效时序

源码从修订 7 变为 8 时,旧键即使语义字段相同也已陈旧。失效操作只删除同一 Shader 且修订不等于当前值的条目,不影响其他 Shader。看似简单的全缓存清空会制造无关重编译;只把修订号放在缓存外做一次布尔判断,则容易在某条查询路径遗漏。

// Program.cs: VariantCache.InvalidateShader
public int InvalidateShader(string shader, int currentRevision)
{
    var stale = compiled.Keys
        .Where(k => k.Shader == shader &&
                    k.SourceRevision != currentRevision)
        .ToArray();
    foreach (var key in stale)
        compiled.Remove(key);
    return stale.Length;
}

回归断言

回归覆盖关键字规范化、Pass 隔离、源码修订失效和命中不重编译。固定缓存先接受修订 7 的规范键,两次请求后编译计数仍为 1;切换当前修订为 8 时删除 1 项,剩余 0 项。该结果证明“命中”和“失效”都基于同一结构键,而不是两套字符串约定。

// Program.cs: Probe.Main 回归
Check("keyword_order_is_canonical", () =>
{
    var a = VariantKey.Create("Lit", "Forward", ["FOG", "SKIN"], 7);
    var b = VariantKey.Create("Lit", "Forward", ["SKIN", "FOG", "FOG"], 7);
    if (a != b || a.Digest() != b.Digest())
        throw new Exception("unstable key");
});
Check("pass_partitions_cache", () =>
{
    var a = VariantKey.Create("Lit", "Forward", ["FOG"], 7);
    var b = VariantKey.Create("Lit", "Shadow", ["FOG"], 7);
    if (a == b) throw new Exception("pass omitted");
});
Check("source_revision_invalidates_stale_variant", () =>
{
    var c = new VariantCache();
    c.GetOrCompile(VariantKey.Create("Lit", "Forward", ["FOG"], 7), k => k.Digest());
    if (c.InvalidateShader("Lit", 8) != 1 || c.Count != 0)
        throw new Exception("stale entry remains");
});

执行结果

本次 .NET 9 Release 运行通过四个断言。9.998 ms 包含 SHA-256、断言与证据序列化,不是 Shader 编译性能数据;本文只主张请求合并和修订失效的行为成立。

cd $WORK_DIR/.tmp/daily-labs/2026-07-31-2000
dotnet run -c Release
# exit code: 0
PASS keyword_order_is_canonical
PASS pass_partitions_cache
PASS source_revision_invalidates_stale_variant
PASS cache_hit_does_not_recompile
FIXED requests=2 compiles=1 digest=2a876a0d5092 invalidated=1 remaining=0
RESULT passed=4 failed=0 skipped=0 elapsed_ms=9.998 exit=0

容量边界

当前按 Shader 枚举键进行失效,复杂度为 O(n),适用于编辑器热重载和中等规模缓存。若单次失效扫描进入可见卡顿,或变体数达到数万,应增加 Shader -> keys 反向索引;在测得该信号前,不为一次低频操作维护第二份索引一致性。

生产构建还应把编译器版本、渲染后端和平台能力纳入更外层的磁盘缓存命名空间。本章键只覆盖同一运行环境中的材质语义与源码时间身份,不能证明跨驱动二进制可复用。

架构判断是:关键字集合解决语义身份,源码修订解决时间身份,两者缺一不可。结构键负责正确性,短摘要只负责可观测性;把这条边界保持清楚,才能在减少重复编译的同时避免复用陈旧程序。