技术博客

shader 编译链:GLSL 到 SPIR-V 再到 RD 反射

第 47 / 149 篇 · 2025-06-24 · godot · 渲染管线源码分析着色器

Godot 4 的渲染器是"全 shader 化"的:canvas、scene、sky、cluster 构建,全部逻辑写在 GLSL 里。一个 .glsl 文件从进仓库到 GPU 执行,中间经过的编译链比多数人想象的长——构建期就编译成 SPIR-V 打包进二进制,运行期再做反射重建布局。我顺着 4.x 的构建系统和 RD 源码把这条链走了一遍,这篇按时间顺序记录。

第一步:构建期,glslang 把 .glsl 编成 .gen.h

引擎自带的 shader 在 servers/rendering/renderer_rd/shaders/ 下,按用途分目录:canvas/、scene/、effects/、forward_clustered/。注意这些文件不是运行时读取的资源,它们在 SCons 构建时就被消化了。看 servers/rendering/renderer_rd/shaders/SCsub:

# servers/rendering/renderer_rd/shaders/SCsub(4.x)
Import("env")
from methods import build_glsl_headers

build_glsl_headers(
    env,
    "servers/rendering/renderer_rd/shaders",
    "*.glsl",
    "servers/rendering/renderer_rd/shaders",
    env["build_profile"]
)

build_glsl_headers(在根目录 methods.py)对每个 .glsl 调一次 glslang 编译成 SPIR-V,然后把字节码序列化成 C 数组,生成 xxx.glsl.gen.h——一个只包含 const uint32_t xxx_spirv[] = { ... }; 的头文件。shader 源码的"源文件"到这一步已经变成了字节码数组,引擎二进制里装的是编译产物,启动时零编译开销。

这样做有个附带好处:shader 和引擎版本严格同步。不会出现"引擎升级到 4.3 但 shader 缓存是 4.2 编出来的"这种错位——字节码是跟着本次构建一起出的。代价是 shader 代码没法在运行期热改引擎自带的 pass,改一行就要重新编译引擎,这也是引擎开发者日常。

.glsl 文件本身不是裸 GLSL,文件头有一段 #[compute] / #[vertex] 的阶段标记和 [common] 共享代码块,build_glsl_headers 会先按标记切分、拼接共享块,再送 glslang。

第二步:运行期,RD 反射 SPIR-V

引擎启动后,渲染初始化调用 shader_create_from_bytecode()(字节码路径)或脚本能用的 shader_create_from_source()(编译路径)。两条路殊途同归,都进 servers/rendering/rendering_device.cpp 的同一个反射器:

// servers/rendering/rendering_device.h(4.x,签名有简化)
Error _reflect_spirv(
    const Vector<ShaderStageSPIRVData> &p_spirv,
    ShaderReflection &r_reflection
);

RD 不依赖外部反射库(SPIRV-Cross 在引擎里是给 RenderingDevice 之外的用途准备的),它的反射器直接解析 SPIR-V 的 OpDecorate / OpTypeStruct / OpVariable 指令流,把 descriptor set、binding、UBO 成员偏移、push constant 大小全部还原成 ShaderReflection 结构。为什么要自己反射而不是信任声明?因为uniform buffer 的内存布局是 shader 说了算的:GDScript 侧 uniform_buffer_create 后要按反射结果填充数据,布局错位就是静默错数据。反射是 SPIR-V 字节码 → RD 资源描述的唯一权威来源。

反射结果决定了几件事:

  • uniform set 里每个 binding 的类型(UBO / SSBO / texture / sampler / image);
  • UBO 的大小和成员偏移,喂给 uniform_buffer_update;
  • pipeline layout(Vulkan 概念),创建 graphics/compute pipeline 时要用。

第三步:pipeline 缓存

shader RID 本身还不是可执行状态。第一次用它画东西时,RD 按"shader + 顶点格式 + blend 状态 + render pass 格式"组合出 pipeline,这个组合数以千计,全量编译启动要几分钟——所以 4.x 把 pipeline 组合缓存到磁盘(--cache 目录下的 pipeline cache 数据)。第一次运行某个 shader 变体时编译,之后命中缓存秒建。这就是为什么 Godot 游戏首次启动比之后慢:变体在补缓存。渲染效果篇会展开变体爆炸问题。

用户路径:shader_create_from_source

GDScript 侧直接写 shader 走的是另一条入口:

var source := RDShaderSource.new()
source.language = RDShaderSource.LANGUAGE_GLSL
source.set_stage_source(RenderingDevice.SHADER_STAGE_COMPUTE, glsl_code)
var spirv := rd.shader_compile_spirv_from_source(source)
var shader := rd.shader_create_from_spirv(spirv)

shader_compile_spirv_from_source 调的就是构建期用的同一个 glslang 函数——RD 把编译器以函数指针形式注入(RenderingDevice::set_shader_compile_to_spirv_function,见前面 RD 抽象层那篇),servers/ 不直接链 glslang。这是"同一个编译器、两个调用时机":引擎 shader 在构建时编,用户 shader 在运行时编,SPIR-V 格式不变。

整条链一图流

GLSL 到可执行 pipeline 的完整链路 .glsl 源文件 #[compute] 等标记 构建期 glslang SCons SCsub .gen.h 字节码数组 打进引擎二进制 RD 反射 SPIR-V 还原 UBO 布局 shader RID 配顶点格式建 pipeline 用户脚本路径:RDShaderSource → shader_compile_spirv_from_source(运行期 glslang)

一个容易忽略的坑:UBO 成员对齐

反射还原出来的布局必须和 C 侧填充严格一致,而 GLSL 的 std140 对齐规则很反直觉:vec3 占 16 字节而不是 12,数组元素按 16 字节对齐,嵌套结构体按最大成员对齐。GDScript 的 PackedByteArray 填 UBO 时如果按"自然打包"算偏移,跑在 NVIDIA 上可能正常、跑在 Mali 上直接花屏——std140 是规范,但部分驱动对未定义行为宽容。稳妥做法:要么全用 vec4,要么严格按 16 字节倍数排布 vec3 和标量。RD 反射结果里每个成员的偏移都明确给了出来,按它填,别自己猜。

本文基于 Godot 4.2/4.3 源码:servers/rendering/renderer_rd/shaders/SCsub、根目录 methods.py: build_glsl_headers、servers/rendering/rendering_device.h 的 _reflect_spirv 与 shader_create_from_* 家族。