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);
}

键的边界
缓存状态由 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();
}

命中时序
正常时序是材质收集关键字、去重排序、构造结构键、查询缓存、缺失时编译并存储。固定输入先以 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 反向索引;在测得该信号前,不为一次低频操作维护第二份索引一致性。
生产构建还应把编译器版本、渲染后端和平台能力纳入更外层的磁盘缓存命名空间。本章键只覆盖同一运行环境中的材质语义与源码时间身份,不能证明跨驱动二进制可复用。
架构判断是:关键字集合解决语义身份,源码修订解决时间身份,两者缺一不可。结构键负责正确性,短摘要只负责可观测性;把这条边界保持清楚,才能在减少重复编译的同时避免复用陈旧程序。