- 游戏开发
- 图形学
- VR
【免费下载链接】stride
Stride (formerly Xenko), a free and open-source cross-platform C# game engine.
Stride(前身 Xenko)引擎在 Stride.Shaders.Parsers/Spirv 目录下实现了一条将 SDSL 着色语言编译为 SPIR-V 的中间流水线,其中 Mixin 继承与合并是链接多段着色器代码的核心环节。本文以 thinking.md 中记录的 Mixin 合并示意为主线,结合仓库中的 Builder、Context、Instruction 生成代码与反汇编工具,梳理 Mixin 的声明、导入、继承、类型展开与 ID 偏移重映射机制,帮助你理解 Stride 渲染后端如何把多个 shader class 拼装成一个可编译的 SPIR-V 模块。
从 Mixin 语法到 SPIR-V 指令
SDSL 中一段着色器类(shader class)被称为 Mixin。在 SPIR-V 中间表示里,每个 Mixin 由三段式结构描述:
MixinName:声明该 Mixin 的名字,对应 SPIR-V 指令OpShaderSDSL;MixinInherit:声明继承的父 Mixin,对应OpMixinInheritSDSL;MixinImport:把父 Mixin 的指令(尤其是类型定义)整体导入当前命名空间,对应OpImportShaderSDSL;MixinEnd:标记该 Mixin 的指令流结束,对应OpShaderEndSDSL。
Builder.Class.cs 中的ShaderClassInstantiation与ShaderMixinInstantiation记录类直接对应这些语法要素:
public record class ShaderClassInstantiation(string ClassName, ConstantExpression[] GenericArguments, bool ImportStageOnly = false) public record class ShaderMixinInstantiation(List<ShaderClassInstantiation> Mixins, Dictionary<string, ShaderMixinInstantiation[]> Compositions)ShaderClassInstantiation封装了一个着色器类的名字、泛型实参和是否仅导入 stage 的标记;ShaderMixinInstantiation则用于记录整个 Mixin 组合的实例化结果。
在生成的指令元数据中,InstructionInfo.gen.cs 将OpMixinInheritSDSL的操作数登记为shader(IdRef)与flags(MixinInheritFlags),后者在 SDSLSpecification.gen.cs 中定义:
[Flags] public enum MixinInheritFlagsMask { None = 0, NeedsFullImport = 1, }其中NeedsFullImport标志用于指示该继承需要在合并时进行完整导入,是后续 ID 处理的重要开关。
三种 Mixin 的合并示例解读
thinking.md 给出了三个 Mixin 的原始声明片段:
MixinName "MixinA" %1 = OpTypeFloat 32 %2 = OpTypeVector %1 2 MixinEnd MixinName "MixinB" MixinInherit "MixinA" %1 MixinImport "MixinA" 1 %2 = OpTypeVector %1 3 %3 = OpTypeMatrix %2 3 MixinEnd MixinName "MixinC" MixinInherit "MixinA" %1 MixinImport "MixinA" 1 %2 = OpTypeVector %1 4 %3 = OpTypeMatrix %2 4 MixinEndMixinA定义了float(OpTypeFloat 32)与float2(OpTypeVector %1 2)两个类型;MixinB继承MixinA,通过MixinImport拉入其类型定义,随后定义float3与float3x3(OpTypeMatrix %2 3);MixinC同样继承MixinA,定义float4与float4x4。
当多个 Mixin 被合并进同一个着色器时,%1、%2这类局部 ID 必然产生冲突。合并算法需要做到两件事:消除指令(如MixinName、MixinInherit、MixinEnd这些仅在声明期有意义的指令)与保留指令(类型定义、导入),并为保留的指令分配新的偏移 ID。
合并后的指令状态转换
thinking.md 的第二段给出了合并算法对上述示例的逐行标注(-->右侧为合并结果):
MixinName "MixinA" --> OpNop %1 = OpTypeFloat 32 --> Keep %2 = OpTypeVector %1 2 --> Keep MixinEnd --> OpNop MixinName "MixinB" --> OpNop MixinInherit "MixinA" --> OpNop %1 MixinImport "MixinA" 1 --> Keep + offset id %2 = OpTypeVector %1 3 --> Keep + offset id %3 = OpTypeMatrix %2 3 --> Keep MixinEnd MixinName "MixinC" MixinInherit "MixinA" %1 MixinImport "MixinA" 1 %2 = OpTypeVector %1 4 %3 = OpTypeMatrix %2 4 MixinEnd合并规则可以归纳为四条:
- 声明性指令变空指令:
MixinName、MixinInherit、MixinEnd在合并阶段被改写为OpNop(空指令),保留占位、不产生任何语义; - 类型定义保留:
OpTypeFloat、OpTypeVector、OpTypeMatrix等类型指令原样保留; - 导入指令保留并偏移:
MixinImport在导入方实例化时保留,但其引用的 ID 需要按新模块的偏移量重映射; - ID 偏移重映射:所有被保留指令引用的局部 ID(如
%1、%2、%3)都要加上偏移量,使它们在合并后的全局命名空间中唯一。
OpNop的填充在 Builder.Class.cs 中通过SetOpNop实现:
public static void SetOpNop(Span<int> words) { words[0] = words.Length << 16; words[1..].Clear(); }它保留指令原有的 word 长度(写入长度前缀),把其余 word 清零,使废弃指令不破坏整个指令流的 word 对齐。
ID 重映射:RemapIds 与 CollectIds
合并时 ID 冲突的核心解决逻辑位于 Builder.Class.cs 的RemapIds与CollectIds。CollectIds遍历一条指令的所有操作数,把IdRef、IdResult、IdResultType、IdScope、IdMemorySemantics以及成对操作数(PairIdRefIdRef、PairIdRefLiteralInteger、PairLiteralIntegerIdRef)中的 ID 全部收集起来:
public static void CollectIds(OpData i, Action<int> ids) { foreach (var op in i) { if (op.Kind == OperandKind.IdRef || op.Kind == OperandKind.IdResult || op.Kind == OperandKind.IdResultType || op.Kind == OperandKind.IdScope || op.Kind == OperandKind.IdMemorySemantics || op.Kind == OperandKind.PairIdRefIdRef) { foreach (var word in op.Words) ids(word); } ... } }RemapIds则按一张Dictionary<int, int>映射表改写指令中的 ID,并处理两个特例:
OpName/OpDecorate等元数据指令:如果其目标 ID 已被重映射(说明目标指令已被替换),则整条指令改写为OpNop,避免悬挂引用;OpEntryPoint接口列表:重映射后可能出现重复 ID,代码用HashSet<int>去重并重新切片接口列表。
if (i.Op == Op.OpEntryPoint && op.Quantifier == OperandQuantifier.ZeroOrMore) { var entryPoint = new OpEntryPoint(ref i); var existing = new HashSet<int>(); var target = 0; for (int index = 0; index < entryPoint.InterfaceIds.Elements.Length; ++index) { if (existing.Add(entryPoint.InterfaceIds.Elements.Span[index])) entryPoint.InterfaceIds.Elements.Span[target++] = entryPoint.InterfaceIds.Elements.Span[index]; } entryPoint.InterfaceIds = entryPoint.InterfaceIds.Slice(0, target); }这正是 thinking.md 中Keep + offset id标注在实现层面的体现:类型指令被保留,但其内部引用的 ID 被整体平移,同时清除声明期指令与失效的调试/装饰信息。
双缓冲结构与 Mixin 导入
合并的物理基础是SpirvContext(类型与常量等上下文信息)与SpirvBuffer(着色器主体指令流)的分离。在 Builder.cs 中,SpirvBuilder持有一个SpirvBuffer,并提供Insert、InsertData、Merge等底层操作;Merge把另一个 buffer 的全部指令拷贝插入到当前位置:
public void Merge(SpirvBuffer other) { var instructions = new List<OpData>(); foreach (var instruction in other) instructions.Add(instruction.Data); buffer.InsertRange(Position, instructions.AsSpan()); Position += other.Count; }UseTemporaryBufferHelper则允许构建器临时切换到一块暂存 buffer(用于在上下文中构造常量或临时类型),Dispose时自动恢复,避免污染主指令流。
ShaderBuffers.CreateFromSpan(Builder.Class.cs)展示了如何从一段含 Magic Number 的 SPIR-V span 重建双缓冲:逐条解析指令,遇到OpShaderSDSL之前的所有指令归入context,之后的指令归入主buffer:
while (wid < span.Length) { var instruction = new OpData(span.Slice(wid, span[wid] >> 16)); if (instruction.Op == Op.OpShaderSDSL) isContext = false; (isContext ? context.GetBuffer() : buffer).Add(instruction); wid += span[wid] >> 16; }这也解释了为什么OpMixinInheritSDSL、OpImportShaderSDSL、OpGenericParameterSDSL这些“声明期”指令都存在于 context 中——它们描述类之间的继承与导入关系,而类型、函数主体等真正需要合并的内容才进入 buffer。
继承链构建与泛型解析
BuildInheritanceListWithoutSelf/BuildInheritanceListIncludingSelf(Builder.Class.cs)是合并 Mixin 前的重要准备:它们把当前着色器类的继承链(不含自身 / 含自身)展开成List<ShaderClassInstantiation>,期间处理泛型实参的传递与引用解析。
在 mix 阶段(ResolveStep.Mix),泛型必须完全解析为常量值,GenericResolverFromInstantiatingBuffer.ValidateGenericParameters会强制校验:
if (resolveStep == ResolveStep.Mix) { if (!genericParameters.All(x => x.Resolved)) throw new InvalidOperationException("During mix phase, shaders generics are expected to be fully resolved"); }InstantiateGenericShader遍历 context 中的OpGenericParameterSDSL,用GenericResolver把泛型参数替换为实际常量(支持 int/float/bool 解析与常量表达式缓冲),并把OpGenericReferenceSDSL沿继承链逐层解析(代码注释中列举了ShadowMapReceiverBase → ShadowMapReceiverDirectional → concrete的传递链示例)。
这些逻辑对应 thinking.md 中MixinImport "MixinA" 1的1——它表示从导入的父类中取得第 1 个结果(即%1对应的OpTypeFloat),泛型解析器在导入时把这类引用映射到父类的真实 ID。
反汇编验证:把合并结果打印出来
合并结果是否符合预期,最终要交给反汇编器检验。Spv.Dis(Tools/Dis.cs)提供从SpirvBuffer、ShaderBuffers、SpirvBytecode、SpirvReader四种输入的文本反汇编,DisassemblerFlags控制输出是否附带 ID、名称或指令序号:
[Flags] public enum DisassemblerFlags { Id = 1, Name = 2, InstructionIndex = 4, }DisWriter.Disassemble先扫描全部OpName/OpMemberName建立名称表(重名自动追加_1、_2后缀),再逐条输出指令;AppendResultId会按名称或数字 ID 对齐输出%name = ...的格式。反汇编头部还会打印模块的版本、Generator、Bound 与 Schema:
; SPIR-V ; Version: 1.6 ; Generator: ... ; Bound: ... ; Schema: 0调试时可以在断点处调用buffer.GetDebuggerDisplay()(其实现即为Spv.Dis(... Id | InstructionIndex | Name)),直接观察合并后的指令流中OpNop与偏移后的类型 ID,与 thinking.md 的标注逐条对照。
合法性校验与 HLSL 输出前的准备
合并完成的模块在进入 HLSL 生成前还需要经过验证与优化。SpirvTools.Validate(Tools/SpirvTools.cs)通过 P/Invoke 调用stride_spirv_tools原生库中的spvValidateBinary/spvValidateWithOptions,ValidatorOptions支持三种放宽布局规则:
RelaxBlockLayout:对应 VulkanVK_KHR_relaxed_block_layout;UniformBufferStandardLayout:对应VK_KHR_uniform_buffer_standard_layout;ScalarBlockLayout:对应VK_EXT_scalar_block_layout。
校验失败时,ResolveSourceLocation会回溯OpString/OpLine调试信息,把诊断定位到file:line:col。
Spv.ValidateBinary(Tools/Validator.cs)封装了两种目标环境:通用模式使用Universal_1_6且不带布局选项;targetVulkan: true时切换到Vulkan_1_4并附加RelaxBlockLayout | UniformBufferStandardLayout:
var env = targetVulkan ? SpirvTools.TargetEnv.Vulkan_1_4 : SpirvTools.TargetEnv.Universal_1_6; var options = targetVulkan ? SpirvTools.ValidatorOptions.RelaxBlockLayout | SpirvTools.ValidatorOptions.UniformBufferStandardLayout : SpirvTools.ValidatorOptions.None;LegalizeForHlsl则运行一条针对 SPIRV-Cross HLSL 输出的合法化流水线(等价于spirv-opt --legalize-hlsl),代码注释指出:Stride 会把包含所有 stage 的单一合并模块交给优化器,而 spirv-opt 没有跨 stage 感知,因此必须保留每个 stage 的 Input/Output 变量(preserveInterface: true),否则下游 stage 会持有没有生产者的输入,FXC 将报 "Semantic X defined for mismatched hardware registers" 或 "Signatures between stages are different lengths"。
小结
Stride 的 Mixin 合并机制可以概括为一条清晰的处理链:
- 解析:SDSL 的
MixinName/MixinInherit/MixinImport被编码为OpShaderSDSL/OpMixinInheritSDSL/OpImportShaderSDSL等指令,继承标志NeedsFullImport决定导入强度; - 展开:
BuildInheritanceList*沿继承链构建实例化列表,泛型在 mix 阶段被强制解析为常量; - 合并:声明期指令改写为
OpNop,类型等有效指令保留,RemapIds按偏移重映射全部 ID 引用,并清理失效的调试/装饰指令与重复的 EntryPoint 接口; - 验证与输出:
Spv.Dis反汇编检查中间结果,SpirvTools.Validate做合法性校验,LegalizeForHlsl在保留 stage 接口的前提下优化模块供 SPIRV-Cross 生成 HLSL。
如果想深入跟踪这条流水线,建议从 thinking.md 的示例出发,对照 Builder.Class.cs 的RemapIds/SetOpNop与 Tools/Dis.cs 的反汇编输出逐条验证,这是理解 SPIR-V 级着色器链接最直观的路径。
- 游戏开发
- 图形学
- VR
【免费下载链接】stride
Stride (formerly Xenko), a free and open-source cross-platform C# game engine.
相关推荐
终极指南:MoltenVK着色器编译全流程解析——从SPIR-V到MSL的无缝转换
终极指南:MoltenVK着色器编译全流程解析——从SPIR V到MSL的无缝转换 MoltenVK作为Vulkan Portability的关键实现,通过将高
图形学kubespy 安装与配置:5分钟快速开始的完整指南
kubespy 安装与配置:5分钟快速开始的完整指南 kubespy 是一款基于 Pulumi 的 Kubernetes 实时资源观察工具,能够帮助开发者实时追
Video2X着色器编译:GLSL到SPIR-V离线转换终极指南
Video2X着色器编译:GLSL到SPIR V离线转换终极指南 Video2X是一款基于机器学习的无损视频/GIF/图像超分辨率放大和帧插值工具,支持waif
音视频视频处理图像处理深度学习
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考