一、概述
在我们的渲染器中,着色器支持涉及的接口和代码比其他任何主要渲染组件都要多,这是理所当然的:着色器是现代3D引擎设计的核心。即使是支持OpenGL ES 2.0的移动设备,也具备功能强大的顶点着色器和片元着色器。如今的桌面设备拥有高度可编程的管线,包括可编程的顶点、细分控制(Tessellation Control)、细分评估(Tessellation Evaluate)、几何和片元着色阶段。在Direct3D中,细分阶段分别称为外壳着色器和域着色器,而片元着色器被称为像素着色器。我们使用 OpenGL 术语。
本节专注于抽象OpenGL 3.x和Direct3D 10类硬件支持的可编程阶段:顶点、几何和片元阶段。
二、编译和链接着色器
首先,我们通过一个简单的例子来考虑编译和链接着色器:
std::string vs = // ...
std::string fs = // ...
ShaderProgram sp = Device::createShaderProgram(vs, fs);
两个字符串vs
和fs
分别包含顶点着色器和片元着色器的源代码。几何着色器是可选的,在这里没有展示。源代码可以是一个硬编码的字符串、由不同代码片段生成的字符串,或者从磁盘读取的字符串。我们的大多数示例将着色器源代码存储在.glsl
文件中,这些文件与 C++ 源文件分开。这些文件被包含在 C++ 项目中,并被标记为嵌入式资源,以便它们被编译进程序集。在设计时,着色器在单独的文件中,但在运行时,它们被嵌入到.dll
或.exe
文件中。着色器的源代码可以通过一个辅助函数在运行时获取,该函数根据资源的名称获取一个字符串:
std::string vs = loadTextFile("include/renderer/drivers/GL3x/GLSL/globeVS.glsl");
Patrick 说:
多年来,我以多种方式组织着色器,我确信嵌入式资源是最方便的。由于着色器是程序集的一部分,这种方法在运行时不需要额外的文件,但在设计时仍然提供了单独源文件的便利性。与硬编码的字符串和在运行时程序化生成的着色器不同,作为资源嵌入的着色器可以很容易地在第三方工具中编写。尽管如此,程序化生成的着色器仍然有潜在的性能优势,我在 Insight3D 的几个地方使用了这种方法。对于支持ARB着色器子例程的硬件,由于可以在运行时选择不同的着色器子例程,类似于虚函数调用,因此程序化生成着色器的需求应该会减少。
包含着色器源代码的字符串被传递给Device::createShaderProgram
以创建一个着色器程序,该程序完全构建好并准备好用于渲染(当然,用户可能希望先设置统一变量)。着色器程序是绘制状态(DrawState)的一部分。它不需要像在原生 GL 或 D3D 【1】中那样绑定到上下文中;相反,着色器程序被指定给每个绘制调用,作为绘制状态的一部分,以避免全局状态。注意,清除帧缓冲区不会调用着色器,因此着色器程序不是清除状态(ClearState)的一部分。
我们的 GL 实现的ShaderProgram
在ShaderProgramGL3x
中,它使用一个辅助类ShaderObjectGL3x
来处理着色器对象。着色器对象代表管线的一个可编程阶段,是 GL 渲染器实现的细节。它不是渲染器抽象的一部分,后者将所有阶段合并到一个着色器程序中。在使用 ARB 分离着色器对象时,暴露单个管线阶段的接口可能会很有用,但我们没有这种需求。
ShaderObjectGL3x
构造函数调用glCreateShader
、glShaderSource
、glCompileShader
和glGetShader
来创建和编译一个着色器对象。如果编译失败,渲染器会抛出自定义的CouldNotCreateVideoCardResourceException
异常。这是渲染器抽象层的一个好处:它可以将错误返回码转换为面向对象的异常。
ShaderProgramGL3x
构造函数创建着色器对象,然后使用glCreateProgram
、glAttachShader
、glLinkProgram
和glGetProgram
将它们链接到一个着色器程序中。类似于编译器错误,链接错误也会被转换为异常。
所有这些GL调用展示了渲染器的另一个好处:简洁性。客户端代码中的一次调用Device::createShaderProgram
会构建一个ShaderProgramGL3x
,它会代表客户端进行许多GL调用,包括适当的错误处理。
除了添加面向对象的构造和使客户端代码更简洁外,渲染器还可以为着色器作者添加有用的构建块。我们的渲染器有内置常量,这些常量对所有着色器可用,但默认情况下不在 GLSL 中定义。这些常量总是以og_
为前缀。一些内置常量如清单3.8所示。这些GLSL常量的值是使用 C++ 的数学常量和 项目中的静态Trig
类中的常量计算得出的。
着色器作者可以直接使用这些常量,而无需显式声明它们。这是通过在ShaderObjectGL3x
中向glShaderSource
提供一个包含两个字符串的数组来实现的:一个包含内置常量,另一个包含实际的着色器源代码。在GLSL中,#version
指令必须是第一行源代码,并且不能重复,因此我们将#version 330
作为内置常量的第一行,并注释掉实际源代码中提供的#version
行。
float og_pi = 3.14159265358979;
float og_oneOverPi = 0.318309886183791;
float og_twoPi = 6.28318530717959;
float og_halfPi = 1.5707963267949;
在编译和链接之后,根据着色器程序的输入和输出,会填充一些集合,如图4-1 所示。我们在以下各节中分别考虑这些集合。
class ShaderProgram {
public:
virtual ~ShaderProgram() = default;
virtual const ShaderVertexAttributeCollection& getVertexAttributes() const = 0;
virtual const FragmentOutputs& getFragmentOutputs() const = 0;
virtual const UniformCollection& getUniforms() const = 0;
// ...
};
三、顶点属性
客户端代码需要能够将顶点缓冲区分配给 GLSL 顶点着色器中命名的顶点属性。这是属性接收数据的方式。每个属性都有一个数值位置,用于建立连接。在链接之后,ShaderProgramGL3x
通过调用glGetProgram
、glGetActiveAttrib
和glGetAttribLocation
查询其活动属性,并填充其公开暴露的顶点着色器中使用的属性集合:顶点属性如清单3.9和图3.6所示。集合中的每个条目都包括属性的名称、数值位置和类型(例如,Float
、FloatVector4
),如下所示:
struct ShaderVertexAttribute {
// ...
std::string getName() const;
int getLocation() const;
ShaderVertexAttributeType getDataType() const;
};
例如,一个顶点着色器可能有两个活动属性:
in vec4 position;
in vec3 normal;
相关的着色器程序会找到并暴露两个属性:
Name | Location | Type | |
---|---|---|---|
sp.getVertexAttributes()[“position”] | “position” | 0 | FloatVector4 |
sp.getVertexAttributes()[“normal”] | “normal” | 1 | FloatVector3 |
这个属性集合被传递给context.createVertexArray
,以将顶点缓冲区连接到属性上。客户端代码可以依赖着色器程序来创建这个集合,但有时,在创建顶点数组之前不必创建着色器程序会更方便。为了实现这一点,需要提前知道属性及其位置。客户端代码可以使用这些信息显式地创建一个ShaderVertexAttributeCollection
,而不是依赖于着色器程序提供的那个。
我们的渲染器包含了一些内置常量,这些常量可以直接在顶点着色器源代码中显式指定顶点属性的位置:
#define og_positionVertexLocation 0
#define og_normalVertexLocation 1
#define og_textureCoordinateVertexLocation 2
#define og_colorVertexLocation 3
顶点着色器中的属性声明将如下所示:
layout(location = og_positionVertexLocation) in vec4 position;
layout(location = og_normalVertexLocation) in vec3 normal;
而不是依赖于着色器程序来构建顶点属性集合,客户端代码可以通过以下代码来完成:
ShaderVertexAttributeCollection vertexAttributes;
vertexAttributes.add(std::make_shared<ShaderVertexAttribute>("position", VertexLocations::Position, ShaderVertexAttribute::FloatVector4, 1));
vertexAttributes.add(std::make_shared<ShaderVertexAttribute>("normal", VertexLocations::Normal, ShaderVertexAttribute::FloatVector3, 1));
在这里,一个静态类VertexLocations
包含了位置和法线的顶点位置常量。VertexLocations
中的常量是 GLSL 常量的 C++ 等价物;实际上,VertexLocations
被用来程序化地创建 GLSL 常量。
客户端代码仍然需要知道着色器程序中使用的属性,但它不必先创建着色器程序。通过将位置指定为内置常量,某些顶点属性的位置(如位置或法线)可以在整个应用程序中保持一致。
四、片段输出(Fragment Outputs)
类似于顶点着色器中定义的顶点属性需要连接到顶点数组中的顶点缓冲区,当使用多个颜色附件时,片段着色器的输出变量也需要连接到帧缓冲区的颜色附件。幸运的是,这个过程比顶点属性的连接过程更简单。
我们的大多数片段着色器只有一个输出变量,通常声明为out vec3 fragmentColor;
或out vec4 fragmentColor;
,具体取决于是否写入 alpha 值。通常,我们依赖固定功能来写入深度值,但有些片段着色器会通过显式赋值给gl_FragDepth
来写入深度值。
当一个片段着色器有多个输出变量时,它们需要连接到相应的帧缓冲区颜色附件。与顶点属性位置类似,客户端代码可以通过向着色器程序查询其输出变量的位置,或者在片段着色器源代码中显式分配位置来完成这一操作。对于前者,ShaderProgram
公开了一个FragmentOutputs
集合,如图4-1所示。这个集合将输出变量的名称映射到其数值位置。它在FragmentOutputsGL3x
中通过glGetFragDataLocation
实现。
例如,考虑一个有两个输出变量的片段着色器:
out vec4 dayColor;
out vec4 nightColor;
客户端代码可以简单地通过输出变量的名称向着色器程序查询其位置,并将纹理分配给该颜色附件位置:
framebuffer.getColorAttachments[sp.getFragmentOutputs("dayColor")] = dayTexture;
或者,片元着色器可以使用与声明顶点属性位置相同的语法显式声明其输出变量的位置:
layout(location = 0) out vec4 dayColor;
layout(location = 1) out vec4 nightColor;
现在,客户端代码可以在创建着色器程序之前就知道输出变量的位置。
五、统一变量(Uniforms)
顶点缓冲区为顶点着色器提供逐顶点变化的数据。典型的顶点属性包括位置坐标、法线向量、纹理坐标和颜色值。这类数据通常每个顶点各不相同,但其他类型的数据变化频率较低,若按顶点存储会造成资源浪费。统一变量(uniform)是为任意着色阶段提供数据的方式,其更新频率最高可达每个图元一次。
由于统一变量在绘制调用前设置(与顶点数据完全分离),它们通常对多个图元保持相同。统一变量的典型应用包括:模型-视图-投影变换矩阵;光照与材质属性(如漫反射与高光分量);应用特定参数(如太阳位置或观察者海拔高度)。
着色器程序会暴露一组活跃的统一变量(如图4.1所示),以便客户端代码检查并修改其值。着色器创作工具可能需要查询程序使用的统一变量,从而提供文本框、颜色选择器等交互控件来调整参数。我们的示例代码大多已预知所需的统一变量,仅需在创建ShaderProgram后或每次绘制调用前设置其值。本节阐述渲染器中的着色器程序如何管理统一变量并通过GL接口修改它们,下节将讨论由渲染器自动设置的统一变量。
我们的渲染器支持UniformType
定义的统一变量数据类型,包括:
- 浮点型(float)、整型(int)和布尔型(bool)标量
- GLM浮点向量:
glm::vec2
/glm::vec3
/glm::vec4
(对应GLSL的vec2/vec3/vec4
) - GLM整型向量:
glm::ivec2
/glm::ivec3
/glm::ivec4
(对应GLSL的ivec2/ivec3/ivec4
) - GLM布尔向量:
glm::bvec2
/glm::bvec3
/glm::bvec4
(对应GLSL的bvec2/bvec3/bvec4
) - GLM浮点矩阵:
-
方阵:
glm::mat2
/glm::mat3
/glm::mat4
(对应GLSL的mat2/mat3/mat4
) -
非方阵:
glm::mat2x3
/glm::mat2x4
/glm::mat3x2
/glm::mat3x4
/glm::mat4x2
/glm::mat4x3
(n×m 矩阵表示 n 列 m 行,与GLSL的 mat nxm 类型完全对应) -
所有矩阵类型均遵循 GLM 的列优先(column-major)内存布局,与 OpenGL 原生规范保持一致。
-
- 用于引用纹理的采样器统一变量,实际存储为整型标量
最常用的统一变量类型包括4×4矩阵、浮点向量和采样器。
#include <string>
#include <string_view>
enum class UniformType { /* ... */ };
template<typename T>
class Uniform {
public:
Uniform(std::string_view name, UniformType type)
: _name(name), _type(type) {}
// 访问器
const std::string& name() const { return _name; }
UniformType type() const { return _type; }
// 值操作
T get() const { return _value; }
void set(const T& value) { _value = value; }
void set(T&& value) { _value = std::move(value); }
private:
std::string _name;
UniformType _type;
T _value{};
};
着色程序链接完成后,ShaderProgramGL3x
通过glGetProgram/glGetActiveUniform/glGetActiveUniformsiv/glGetUniformLocation
等接口收集统一变量信息。例如对于包含以下统一变量的着色器:
uniform mat4 modelViewPerspectiveMatrix;
uniform vec3 sunPosition;
uniform sampler2D diffuseTexture;
创建对应的ShaderProgram
后,其Uniforms
集合将包含三个对象:
名称 | 类型(UniformType) |
---|---|
“modelViewPerspectiveMatrix” | FloatMatrix44 |
“sunPosition” | FloatVector3 |
“diffuseTexture” | Sampler2D |
Uniforms
集合通过基类Uniform
暴露接口,客户端代码需将统一变量转型为具体Uniform<T>
类型来操作值属性。例如设置上述变量的典型代码:
ShaderProgram sp;
sp.matrixUniforms["modelViewPerspectiveMatrix"] = std::make_shared<Uniform<glm::mat4>>("modelViewPerspectiveMatrix");
sp.vectorUniforms["sunPosition"] = std::make_shared<Uniform<glm::vec4>>("sunPosition");
sp.intUniforms["diffuseTexture"] = std::make_shared<Uniform<int>>("diffuseTexture");
// 设置值
auto matrixUniform = sp.getUniform<glm::mat4>("modelViewPerspectiveMatrix");
if (matrixUniform) matrixUniform->setValue(glm::mat4(/* ... */));
auto vectorUniform = sp.getUniform<glm::vec4>("sunPosition");
if (vectorUniform) vectorUniform->setValue(glm::vec4(/* ... */));
auto intUniform = sp.getUniform<int>("diffuseTexture");
if (intUniform) intUniform->setValue(0);
若需在每次绘制调用前设置统一变量,建议提前缓存Uniform<T>
对象避免重复查找:
auto u = sp.getUniform<glm::mat4>("modelViewPerspectiveMatrix");
while (/* ... */) {
u.setValue(/* ... */);
context.draw(/* ... */);
}
当客户端代码不确定具体类型时,可通过Type属性检查后转型。我们的GL实现中,每个Uniform<T>
都有对应的派生类(如图3.7所示),命名规则直观:Uniform<float>
对应UniformFloatGL3x
等。ShaderProgramGL3x.createUniform
根据glGetActiveUniform
返回的类型创建相应的派生类实例。
每个GL派生类会存储统一变量值的副本,并通过glUniform*调用与GL同步。当用户设置Value属性时,GL调用不会立即执行,而是采用延迟处理技术[32]。ShaderProgramGL3x
维护所有统一变量的集合和一个"脏标记"列表。设置值时,统一变量会更新本地副本并将自身加入脏列表(若尚未加入)。执行绘制调用时,绑定到上下文的着色程序会先执行"清理"操作:为脏列表中的每个统一变量调用glUniform*
,然后清空列表。若某程序的多数统一变量在绘制调用间频繁变化,可改为遍历全部统一变量直接提交,高性能引擎可在运行时动态选择最优策略。
这种延迟技术的优势在于:避免客户端代码重复设置Value属性时产生冗余GL调用;设置值时无需当前上下文;简化GL状态管理(glUniform*
要求对应的着色程序已绑定)【2】。Direct3D 9 的常量机制与GL统一变量类似,可通过ID3DXConstantTable
操作。D3D 10 改用常量缓冲区(按更新频率分组的常量集合)提升性能,OpenGL也引入了类似的统一缓冲区对象。
六、自动统一变量(Automatic Uniforms)
统一变量是为着色器提供跨绘制调用恒定值的便捷方式。当前渲染器要求客户端代码显式设置每个着色器中所有活跃的Uniform变量。但诸如模型视图矩阵、相机位置等变量往往在多个绘制调用甚至整个帧周期内保持不变,强制客户端代码为每个着色器显式设置这些变量显然不够高效。若渲染器能预定义一组在绘制调用前自动设置的统一变量,甚至允许客户端扩展该集合,将显著提升开发效率——毕竟使用渲染器的核心价值就在于提供底层API之上的功能抽象(参见3.1节)。
本渲染器实现了一套自动统一变量框架,这类变量在不同引擎中有多种命名:预设变量、引擎变量、引擎参数等。其核心机制相同:着色器作者只需声明特定格式的变量(如mat4 og_modelViewMatrix),系统便会自动赋值而无需客户端干预。
与 GLSL 内置常量类似,本系统自动变量均以og_
前缀标识。自动统一变量分为两种类型:
- 链接时自动变量(Link automatic):着色器编译链接后立即设置,仅执行一次
- 绘制时自动变量(Draw automatic):需在每次绘制调用时更新
绝大多数自动变量属于绘制时自动变量。前者虽使用频率较低,但因无需每帧设置而开销更小。自动变量通过实现LinkAutomaticUniform
或DrawAutomaticUniform
及DrawAutomaticUniformFactory
抽象类来定义。
// 链接时自动变量抽象基类
class LinkAutomaticUniform {
public:
virtual ~LinkAutomaticUniform() = default;
// 获取匹配的uniform名称
virtual const std::string& getName() const = 0;
// 设置uniform值(链接时调用)
virtual void set(Uniform& uniform) = 0;
};
// 绘制时自动变量工厂抽象基类
class DrawAutomaticUniformFactory {
public:
virtual ~DrawAutomaticUniformFactory() = default;
// 获取匹配的uniform名称
virtual const std::string& getName() const = 0;
// 创建对应的自动变量实例
virtual std::unique_ptr<class DrawAutomaticUniform> create(Uniform& uniform) = 0;
};
// 绘制时自动变量抽象基类
class DrawAutomaticUniform {
public:
virtual ~DrawAutomaticUniform() = default;
// 每帧设置uniform值(绘制时调用)
virtual void set(Context&, const DrawState&, const SceneState&) = 0;
};
链接时自动变量只需提供变量名并实现Uniform
赋值逻辑。设备层维护这类变量的集合,当ShaderProgram.initializeAutomaticUniforms
检测到着色器中存在同名变量时,即调用抽象set
方法赋值。
本系统目前唯一的链接时自动变量是og_textureN
,其将采样器自动绑定到第 N 纹理单元(如 sampler2D og_texture1
对应纹理单元1)。
class TextureUniform1 : public LinkAutomaticUniform {
public:
// 获取匹配的 uniform 名称 (og_texture1)
const std::string& getName() const override {
static const std::string name = "og_texture1";
return name;
}
// 设置 uniform 值(将采样器绑定到纹理单元 1)
void set(Uniform& uniform) override {
// 动态转换为 int 类型的 Uniform(即 sampler 类型)
auto& textureUniform = dynamic_cast<Uniform<int>&>(uniform);
textureUniform.set(1); // 绑定到纹理单元 1
}
};
绘制自动统一变量比链接自动统一变量稍微复杂一些。与链接自动统一变量类似,设备存储了所有绘制自动统一变量工厂的集合。与链接自动统一变量不同,当ShaderProgram.initializeAutomaticUniforms
遇到一个绘制自动统一变量时,它不能简单地赋值,因为值可以从一次绘制调用变化到另一次绘制调用。相反,统一变量工厂创建实际的绘制自动统一变量,该变量存储在特定着色器的绘制自动统一变量集合中。在每次绘制调用之前,着色器被“清理”;它遍历其绘制自动统一变量并调用抽象的Set
方法为它们赋值。
自动统一变量的实现独立于GL渲染器,除了ShaderProgramGL3x
需要在编译和链接后调用受保护的InitializeAutomaticUniforms
,并在清理时调用SetDrawAutomaticUniforms
。个别自动统一变量的实现没有任何对底层API的了解。
鉴于清单3.12中DrawAutomaticUniform.Set
的签名,绘制自动统一变量可以使用上下文和绘制状态来确定为其统一变量赋什么值。但这如何帮助像模型视图矩阵和摄像机位置这样的统一变量呢?当然没有帮助。Set
的第三个参数是一个名为SceneState
的新类型,它封装了场景级状态,如变换和摄像机,如清单3.14所示。类似于DrawState
,SceneState
被传递给绘制调用,最终到达自动统一变量的Set
方法。应用程序可以为所有绘制调用使用一个SceneState
,也可以根据需要使用不同的SceneState
。
struct Camera {
glm::dvec3 eye{glm::dvec3(0.0, -1.0, 0.0)};
glm::dvec3 target{glm::dvec3(0.0, 0.0, 0.0)};
glm::dvec3 up{glm::dvec3(0.0, 0.0, 1.0)};
glm::dvec3 forward;
glm::dvec3 right;
double fovy{M_PI / 6.0};
double aspectRatio;
double perspectiveNear{0.01};
double perspectiveFar{1000.0};
};
struct SceneState {
float DiffuseIntensity{0.65f};
float SpecularIntensity{0.25f};
float AmbientIntensity{0.10f};
float Shininess{12.0f};
Camera* camera;
glm::dvec3 sunPosition{200000, 0, 0};
glm::dmat4 modelMatrix{glm::dmat4(1.0)};
double highResolutionSnapScale{1.0};
glm::dvec3 getCameraLightPosition() const;
glm::dmat4 getOrthographicMatrix() const;
glm::dmat4 getPerspectiveMatrix() const;
glm::dmat4 getViewMatrix() const;
glm::dmat4 getModelViewMatrix() const;
glm::dmat4 getModelViewMatrixRelativeToEye() const;
glm::dmat4 getModelViewPerspectiveMatrixRelativeToEye() const;
glm::dmat4 getModelViewPerspectiveMatrix() const;
glm::dmat4 getModelViewOrthographicMatrix() const;
glm::dmat4x2 getModelZToClipCoordinates() const;
};
七、缓存着色器
前面章节讨论了按绘制状态(DrawState)对上下文绘制调用进行排序的性能优势。以下代码被用于按着色器排序:
private:
static int compareDrawStates(DrawState left, DrawState right) {
int leftShader = left.ShaderProgram.GetHashCode();
int rightShader = right.ShaderProgram.GetHashCode();
if (leftShader < rightShader) {
return -1;
} else if (leftShader > rightShader) {
return 1;
}
// ...
// 如果着色器相同,则按其他状态排序
}
为了按着色器排序,我们需要能够比较着色器。我们并不关心最终的排序顺序,只是希望相同的着色器能够排在一起,以最小化实际需要的着色器更改次数(即在GL实现中调用glUseProgram
的次数)。按着色器排序的关键在于,相同的着色器需要是同一个ShaderProgram
实例。
以下代码创建了多少个唯一的ShaderProgram
实例?
std::string vs = // ...
std::string fs = // ...
ShaderProgram sp = Device::createShaderProgram(vs, fs);
ShaderProgram sp2 = Device::createShaderProgram(vs, fs);
尽管两个着色器都是用相同的源代码创建的,但这也创建了两个不同的ShaderProgram
实例,就像进行类似的GL调用一样。
class ShaderCache {
public:
ShaderProgram findOrAdd(std::string key, std::string vertexShaderSource, std::string fragmentShaderSource);
ShaderProgram findOrAdd(std::string key, std::string vertexShaderSource, std::string geometryShaderSource, std::string fragmentShaderSource);
ShaderProgram find(std::string key);
void release(std::string key);
};
为了按着色器排序,我们希望两个着色器是同一个实例,以便比较方法可以将它们排在一起。这就需要一个着色器缓存,它在ShaderCache
中实现。着色器缓存简单地将一个唯一的键映射到一个ShaderProgram
实例。我们使用用户指定的字符串作为键,但也可以使用整数甚至着色器源代码本身。
着色器缓存是渲染器的一部分,但它不依赖于特定的API;它只处理ShaderProgram
。如清单3.16所示,着色器缓存支持添加、查找和释放着色器。引用计数用于跟踪对同一个着色器有引用的客户端数量。当一个着色器被添加到缓存中时,其引用计数为1。每次从缓存中检索它时,无论是通过findOrAdd
还是find
,其计数都会增加。当客户端完成对缓存着色器的使用时,它调用release
,这会减少计数。当计数达到零时,着色器从缓存中移除。
我们创建了两个具有相同着色器源代码的不同ShaderProgram
实例的示例可以重写为使用着色器缓存,以便只创建一个ShaderProgram
实例,使状态排序按预期工作:
std::string vs = // ...
std::string fs = // ...
ShaderCache cache;
ShaderProgram sp = cache.findOrAdd("example key", vs, fs);
ShaderProgram sp2 = cache.find("example key");
// sp 和 sp2 是同一个实例
cache.release("example key");
cache.release("example key");
对find
的调用可以用findOrAdd
替换。区别在于Find
不需要有着色器的源代码,并且如果找不到键则返回nullptr
,而findOrAdd
如果找不到键则会创建着色器。在程序化生成着色器时,find
很有用,因为可以在不程序化生成整个源代码的情况下检查缓存。
客户端代码通常只需要一个着色器缓存,但没有什么能阻止客户端创建多个。ShaderCache
的实现是std::map
或std::unordered_map
的直接使用,它将字符串映射到ShaderProgram
及其引用计数。由于多个线程可能想要访问着色器缓存,因此每个方法都用粗粒度锁保护,以序列化对共享缓存的访问。有关并行性和线程的更多信息,请参阅第10章。
Patrick Says:
当我第一次在Insight3D中实现着色器缓存时,我为着色器程序和着色器对象都创建了缓存。我发现着色器程序缓存对于按着色器排序很有用,但着色器对象缓存除了最小化创建的GL着色器对象数量外,并不是很有用。在我们的渲染器中,这不是问题,因为着色器对象的概念不存在;只暴露整个着色器程序。
参考:
- Cozi, Patrick; Ring, Kevin. 3D Engine Design for Virtual Globes. CRC Press, 2011.
注释:
- 在 Direct3D(D3D)中,不存在“着色器程序”这一概念;相反,各个着色器阶段是绑定到上下文中的。在 OpenGL 中,通过 ARB_separate_shader_objects 扩展,也可以实现类似的功能。
- 采用ARB分离着色器对象还能简化状态管理,因为程序直接传递给glProgramUniform*调用而无需预先绑定。