一、概述
对于渲染器来说,一个重要的设计决策是如何管理渲染状态。渲染状态用于配置管线中的固定功能组件,以进行渲染操作,并且会影响绘制调用。它包括诸如裁剪测试(scissor test)、深度测试(depth test)、模板测试(stencil test)和混合(blending)等内容。
二、全局状态
在原生 OpenGL 中,渲染状态是全局状态,只要调用线程中有一个有效的上下文,就可以随时设置。例如,可以通过以下调用启用深度测试:
glEnable(GL_DEPTH_TEST);
将这种设计暴露给渲染器的最简单方式是模仿 OpenGL 的设计:提供启用(Enable)和禁用(Disable)方法,并定义一个枚举,列出可以启用和禁用的状态。然而,这种设计仍然存在全局状态的根本问题。在任意时刻,深度测试是启用还是禁用?如果调用了一个虚方法,它会将深度测试状态设置为什么?
管理全局状态的一种方法是在调用方法之前保证一组状态,并要求被调用的方法恢复它所改变的任何状态。例如,我们可以约定深度测试始终启用,因此如果一个方法希望禁用深度测试,它必须像下面这样操作:
public virtual void render() {
glDisable(GL_DEPTH_TEST);
// ... 绘制调用
glEnable(GL_DEPTH_TEST);
}
这里显而易见的问题是,这可能导致状态频繁切换(state thrashing),即每个方法都会设置和恢复相同的状态。例如,如果上述方法被调用了 10 次,它就会禁用和启用深度测试 10 次,而实际上只需要操作一次即可。驱动程序可能会优化掉那些实际上没有从一次绘制调用到另一次绘制调用发生变化的状态改变,但调用 OpenGL 仍然会带来一定的驱动开销。这与真正的问题相比只是小巫见大巫:这种设计要求实现该方法的人必须知道传入的状态是什么,并记得恢复它。
假设开发者能够记住传入的状态,我们可以在调用他们的方法时,用 OpenGL 的 push 和 pop 属性调用将其包围,如下所示:
// ... 设置初始状态
glPushAttrib(GL_ALL_ATTRIB_BITS);
glPushClientAttrib(GL_CLIENT_ALL_ATTRIB_BITS);
Render();
glPopClientAttrib();
glPopAttrib();
// ...
virtual void Render()
{
glDisable(GL_DEPTH_TEST);
//... 绘制调用
// 无需恢复
}
在这种设计中,Render
不需要恢复任何状态,因为 push 和 pop 属性已经保存并恢复了状态。由于不知道 Render
会改变哪些状态,所以所有属性都被推入和弹出,这很可能是过度操作。更有趣的是,这些方法在 OpenGL 3 中已经被弃用。与其推送和弹出状态,我们可以通过在每次调用 Render
之前显式设置整个状态来“恢复”状态,例如:
glEnable(GL_DEPTH_TEST);
Render();
和之前一样,这仍然会导致冗余的状态改变,并且要求实现 Render
的人知道传入的状态是什么。
Patrick 说:
我曾经使用过这种方法,传入的状态是明确定义的,并且必须被保留。这有时会很痛苦,因为我总是不得不在编写新代码之前双重检查传入的状态,例如:“这个对象是半透明的,所以深度写入被禁用了,还是没禁用?”
全局渲染状态带来的最后一个问题是,开发者可能会写出看似无害的代码,如下所示:
virtual void Render()
{
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glDepthMask(GL_FALSE);
// ... 第一次绘制调用
DrawCall();
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// ... 第二次绘制调用
AnotherDrawCall();
}
乍看之下,第二趟绘制显然会关闭深度写入。但如果这段代码再添上若干其他状态改动,还会这么“显然”吗?万一某位开发者把 glDepthMask
这一行注释掉,他是只想影响第一趟绘制,还是所有绘制?——糟了。要是每次绘制调用都能完整、明确地指定渲染状态,这些问题不就迎刃而解了吗?
三、定义渲染状态(Render State)
仅仅因为 OpenGL API 使用了全局状态,并不意味着渲染器必须暴露全局状态。全局状态只是一个实现细节。渲染器可以使用任何抽象来呈现渲染状态。一种可以消除全局状态并支持按状态排序的方法是,将所有渲染状态组合到一个对象中,并将该对象传递给绘制调用。然后,绘制调用会在发出 OpenGL 绘制调用之前,实际执行 OpenGL 调用来设置这些状态。在这种设计中,永远不会有关于当前状态是什么的问题——根本就不存在“当前状态”。每次绘制调用都有自己的渲染状态,这个状态可能会在调用之间发生变化,也可能不会。
我们定义了一个 RenderState
类,具有以下属性:
//...
struct RenderState {
PrimitiveRestart primitiveRestart;
FacetCulling facetCulling;
ProgramPointSize programPointSize;
RasterizationMode rasterizationMode;
ScissorTest scissorTest;
StencilTest stencilTest;
DepthTest depthTest;
DepthRange depthRange;
Blending blending;
ColorMask colorMask{true, true, true, true};
bool depthMask;
};
这些属性的类型都是与具体 API 无关的,并且在 Renderer
中定义。它们的名称已经很好地说明了它们的用途。例如,DepthTest
包含一个布尔类型的 Enabled
属性和一个 DepthTestFunction
枚举,该枚举定义了启用深度测试时使用的比较函数:
enum class DepthTestFunction {
Never,
Less,
Equal,
LessThanOrEqual,
Greater,
NotEqual,
GreaterThanOrEqual,
Always
};
struct DepthTest {
bool enabled{true};
DepthTestFunction function{DepthTestFunction::Less};
};
在默认构造时,RenderState
的属性与 OpenGL 的默认状态相匹配,有两个例外。深度测试(DepthTest
)被启用,因为这在我们的引擎中是常见的情况,并且面剔除(RenderState.FacetCulling.Enabled
)也被启用而不是禁用。所有其他 RenderState
属性都类似于 DepthTest
。值得一提的是,模板测试(RenderState.StencilTest
)有单独的正面和背面状态,而混合(RenderState.Blending
)有单独的 RGB 和 alpha 混合因子。这些对象只是容器;它们存储状态,但不会实际设置 OpenGL 的全局状态。
客户端代码只需分配一个 RenderState
,然后设置任何默认值不合适的属性。例如,以下代码定义了用于广告牌(billboards)的渲染状态:
RenderState renderState;
renderState.FacetCulling.Enabled = false;
renderState.Blending.Enabled = true;
renderState.Blending.SourceRGBFactor = SourceBlendingFactor::SourceAlpha;
renderState.Blending.SourceAlphaFactor = SourceBlendingFactor::SourceAlpha;
renderState.Blending.DestinationRGBFactor = DestinationBlendingFactor::OneMinusSourceAlpha;
renderState.Blending.DestinationAlphaFactor = DestinationBlendingFactor::OneMinusSourceAlpha;
我们的 RenderState
和相关类型类似于 D3D 状态对象,它们将状态组合成粗粒度的对象:ID3D11BlendState
、ID3D11DepthStencilState
和 ID3D11RasterizerState
。主要区别在于,D3D 类型是不可变的,因此一旦创建就不能更改。不可变类型允许一些优化,但它们也降低了客户端代码的灵活性。
例如,对于一个可变的渲染状态,一个对象可以根据其 alpha 值在渲染之前确定其深度写入属性。而对于一个不可变的渲染状态,对象要么需要在其深度写入属性发生变化时创建一个新的渲染状态,要么保留两个渲染状态,根据其 alpha 值进行选择。
D3D 状态对象和我们的 RenderState
之间的另一个区别是,D3D 状态对象仍然使用诸如 ID3D11DeviceContext::OMSetBlendState
这样的方法分配到全局状态,而我们通过直接将对象传递给绘制调用来完全消除全局渲染状态。
Patrick 说:
当我最初为 Insight3D 设计渲染状态时,我使用了一种基于“模板”创建的不可变类型,该模板定义了实际的状态。模板被传递给一个全局工厂,该工厂要么创建一个新的渲染状态,要么从其缓存中返回一个。好处是,由于所有渲染状态都是已知的,因此可以使用桶排序(bucket sort)按状态排序。问题是,客户端代码总是释放渲染状态并请求新的状态。我最终决定,可变渲染状态的灵活性超过了不可变渲染状态的性能优势,并将实现切换为使用在绘制时使用比较排序(例如,std::sort
)的可变渲染状态。Ericson 在《God of War III》中描述了类似的灵活性与性能权衡,用于状态排序。
四、同步 OpenGL 状态与渲染状态
到目前为止,我们已经将 RenderState
定义为一个容器对象,用于存储在绘制调用期间影响管线固定功能配置的渲染状态。为了应用一个 RenderState
,它被传递给绘制调用,如下所示:
RenderState renderState;
// ... 设置状态
context.draw(PrimitiveType::Triangle, renderState, /* ... */);
当然,每次绘制调用之前并不需要分配一个新的 RenderState
。一个对象可以在构造时分配一个或多个 RenderState
,并在需要时设置它们的属性。相同的 RenderState
也可以与不同的上下文一起使用。
ContextGL3x.draw
的实现正如你所期望的那样。它使用细粒度的 OpenGL 调用来将 OpenGL 状态与传入的状态同步。上下文维护了一个“影子”副本的当前状态 _renderState
,以便它可以避免对不需要改变的状态进行 OpenGL 调用。例如,为了设置深度测试状态,Context.draw
调用了 applyDepthTest
:
private:
void applyDepthTest(DepthTest depthTest)
{
if (_renderState.DepthTest.Enabled != depthTest.Enabled)
{
enable(EnableCap::DepthTest, depthTest.Enabled);
_renderState.DepthTest.Enabled = depthTest.Enabled;
}
if (depthTest.Enabled)
{
if (_renderState.DepthTest.Function != depthTest.Function)
{
glDepthFunc(static_cast<GLenum>(depthTest.Function));
_renderState.DepthTest.Function = depthTest.Function;
}
}
}
protected:
static void enable(EnableCap enableCap, bool enable)
{
if (enable)
{
glEnable(static_cast<GLenum>(enableCap));
}
else
{
glDisable(static_cast<GLenum>(enableCap));
}
}
为了设置深度函数,我们的渲染器的枚举值通过 TypeConverterGL3x.To
转换为 OpenGL 的值。这可以通过一系列的 if ... else
语句、switch
语句或表查找来实现。一个健壮的实现应该验证枚举值,并在适当的情况下断言或抛出异常。除非渲染器的枚举值与 OpenGL 的值匹配,否则不能直接将枚举值强制转换为 OpenGL 类型。
Renderer.GL3x.ContextGL3x
中的大部分代码就像上面的代码片段一样设置 OpenGL 状态。在影子状态时,重要的是影子副本永远不要与 OpenGL 状态不同步。在上下文被构造时,OpenGL 状态应该与影子状态同步(参见 ContextGL3x.forceApplyRenderState
),并且在进行 OpenGL 状态更改时,影子状态应该始终被更新。
试一试:
如果使用相同的渲染状态进行多次绘制调用,那么可能值得避免所有细粒度的if
语句,这些语句比较影子状态与传入的渲染状态的各个属性。通过记住最后使用的渲染状态实例及其“版本号”(每次渲染状态的属性发生变化时,这个整数就会递增),实现一个快速接受的粗粒度检查。如果传入的渲染状态引用等于影子渲染状态引用,并且它们的版本号匹配,表明上次绘制调用中使用了相同的渲染状态,那么ContextGL3x.draw
可以跳过所有细粒度的检查。
五、定义绘制状态(Draw State)
尽管我们描述了 Context.Draw
会接收一个 RenderState
,但它实际上接收的是一个更高层次的容器对象,称为 DrawState
。为了发出绘制调用,需要渲染状态来配置固定功能管线,但还需要其他状态来配置管线的其他部分。值得注意的是,需要一个响应绘制调用而执行的着色器程序,以及一个引用顶点缓冲区的顶点数组和一个可选的索引缓冲区。
在 OpenGL 中,这些是全局状态;着色器程序通过 glUseProgram
指定,顶点数组通过 glBindVertexArray
指定,均在发出绘制调用之前进行设置。在 Direct3D 中,这些也是全局状态,使用 ID3D11DeviceContext
方法绑定;着色器阶段通过诸如 VSSetShader
和 PSSetShader
的 SetShader
方法绑定,顶点数组状态通过诸如 IASetVertexBuffers
和 IASetIndexBuffer
的 IA*
方法绑定。
这些全局状态导致与全局渲染状态相同的问题。解决方案也相同:将它们组合到一个传递给绘制调用的容器中。DrawState
就是这样的容器;它包括用于绘制的渲染状态、着色器程序和顶点数组。
struct DrawState {
// ... 构造函数
RenderState* renderState;
ShaderProgram* shaderProgram;
VertexArray* vertexArray;
}
这也有助于按着色器排序。显示 DrawState
属性的图表见图 3.1。绘制调用不仅仅是为着色器程序调用 glUseProgram
和为顶点数组调用 glBindVertexArray
。
Patrick 说:
当我最初设计Context.Draw
时,它确实只接收一个RenderState
对象。客户端代码负责在调用Draw
之前将着色器程序和顶点数组“绑定”到上下文中。然后我意识到这些是不必要的全局状态,与全局渲染状态存在相同的问题,所以我将它们与RenderState
组合到一个名为DrawState
的更高层次的容器中。在抽象任何 API 时,重要的是要记住你不需要让该 API 的实现细节在你的设计中暴露出来。这是一个值得追求的理想,但有时说起来容易做起来难。
六、定义清空状态(Clear State)
许多影响绘制调用的状态也会影响清空帧缓冲区。清空与绘制不同,因为几何数据不需要通过管线,也不会执行着色器程序。尽管可以通过渲染一个全屏四边形来清空帧缓冲区,但这种做法并不好,因为它没有利用快速清空的优势,而快速清空可以正确初始化用于压缩和分层 Z 剔除的缓冲区 [136]。
在 OpenGL 中,清空受多种状态的影响,包括裁剪测试(scissor test)以及颜色、深度和模板掩码。清空还依赖于通过 glClearColor
、glClearDepth
和 glClearStencil
分别设置的颜色、深度和模板清空值状态。在配置好这些状态后,通过调用 glClear
来清空一个或多个缓冲区。
Direct3D 的设计不同,它不依赖于全局状态;ID3D11DeviceContext::ClearRenderTargetView
用于清空渲染目标(例如,颜色缓冲区),而 ID3D11DeviceContext::ClearDepthStencilView
用于清空深度和/或模板缓冲区。清空值作为参数传递,而不是全局状态。没有为深度和模板缓冲区提供单独的清空方法;将它们一起清空更高效,因为它们通常存储在同一个缓冲区中。
我们的清空设计类似于 Direct3D,尽管它是使用 OpenGL 实现的。它使用一个容器对象 ClearState
,如图 3.2 所示,来封装清空所需的状态。
其中的一个成员 ClearBuffers
是一个位掩码,用于定义要清空哪些缓冲区。要清空帧缓冲区中的一个或多个缓冲区,需要创建一个 ClearState
对象并将其传递给 Context.Clear
。例如,以下客户端代码仅清空深度和模板缓冲区:
ClearState clearState;
clearState.Buffers = ClearBuffers::DepthBuffer | ClearBuffers::StencilBuffer;
context.clear(clearState);
ContextGL3x.Clear
的实现是一系列基于传入的 ClearState
的简单 OpenGL 调用。类似于影子渲染状态,颜色、深度和模板清空值也会被影子化,以避免不必要的 OpenGL 调用。
enum class ClearBuffers {
ColorBuffer = 1,
DepthBuffer = 2,
StencilBuffer = 4,
ColorAndDepthBuffer = ClearBuffers::ColorBuffer | ClearBuffers::DepthBuffer,
All = ClearBuffers::ColorBuffer | ClearBuffers::DepthBuffer | ClearBuffers::StencilBuffer
};
struct ClearState {
// ... 构造函数
ScissorTest scissorTest;
ColorMask colorMask;
bool depthMask;
int frontStencilMask;
int backStencilMask;
ClearBuffers buffers;
Color color;
float depth;
int stencil;
};
需要注意的是,在 Direct3D 中裁剪测试不影响清空,但在 OpenGL 中它会影响清空,这为可移植性设计渲染器存在一些挑战。在这种情况下,如果同时支持 OpenGL 和 Direct3D,可以从 ClearState
中移除裁剪测试,并在清空时始终禁用它。或者,Direct3D 的实现可以在裁剪测试启用时断言或抛出异常,尽管这会迫使客户端代码知道它正在使用哪种渲染器。
七、按状态排序
按状态排序是一种常见的优化手段。这样做可以避免浪费 CPU 驱动开销,并通过最小化管线停顿来利用 GPU 的并行性。最昂贵的状态改变是那些需要大规模重新配置管线的状态,例如更改着色器、深度测试状态或混合状态。
状态排序并不是由我们的渲染器本身完成的,而是可以通过对 DrawState
进行排序并以最小化昂贵状态改变的顺序发出 Context.draw
调用来实现的。按纹理排序或在单个纹理中合并多个纹理也很常见。
使用状态排序渲染场景的一种方法是采用三遍过程:
- 首先,分层剔除确定可见对象的列表。
- 接下来,按状态对可见对象进行排序。
- 最后,按排序顺序绘制可见对象。
当然,存在许多变体。如果在遍历场景之前已知所有状态,则可以将前两遍合并,状态排序基本上可以免费获得。在初始化期间,为每种可能的状态分配一个已排序的桶列表。每帧渲染分为两遍:
- 分层剔除确定可见对象,并根据其状态将每个对象放入一个桶中。如果为每种状态分配一个唯一的索引,则可以根据状态将对象映射到桶中,这可以在 O(1) 时间内完成。
- 接下来,遍历已经排序的桶,并绘制非空桶中的对象。渲染一个桶后,将其清空,为下一帧做好准备。
这种设计假设在初始化时已知所有可能的状态,并且可能状态的数量与对象数量相当。也就是说,如果存在 100,000 种可能的状态,而只有十个对象,这种设计就不理想了。
对于某些场景,分层剔除并非必需。若能提前确定状态集合,且物体状态保持不变,那么整个场景可以通过单次渲染完成:只需按排序顺序绘制物体,并在绘制过程中根据需要决定是否进行单独剔除。
无论状态排序何时发生,都需要定义排序顺序。这可以很容易地通过我们的 DrawState
对象使用比较方法来完成,如CompareDrawStates
。此方法在 left < right
时返回 -1,在 left > right
时返回 1,在 left = right
时返回 0。为了按状态排序,可以将 CompareDrawStates
函数对象传递给 std::sort
。
比较方法应该首先比较最昂贵的状态,然后比较较不昂贵的状态,直到可以确定顺序为止。CompareDrawStates
方法首先按着色器排序,然后按深度测试是否启用排序。它应该继续进行,比较其他渲染状态,如深度比较函数和混合状态。
CompareDrawStates
实现的问题在于,随着比较更多状态,代码变得冗长,且大量的分支可能会在每帧排序时影响性能。
private:
static int CompareDrawStates(const DrawState& left, const DrawState& right)
{
// 首先按着色器程序排序
int leftShader = left.ShaderProgram.getHashCode();
int rightShader = right.ShaderProgram.getHashCode();
if (leftShader < rightShader)
{
return -1;
}
else if (leftShader > rightShader)
{
return 1;
}
// 着色器程序相同,比较深度测试是否启用
int leftEnabled = left.RenderState.DepthTest.Enabled ? 1 : 0;
int rightEnabled = right.RenderState.DepthTest.Enabled ? 1 : 0;
if (leftEnabled < rightEnabled)
{
return -1;
}
else if (rightEnabled > leftEnabled)
{
return 1;
}
// 继续按其他状态的顺序比较,从最昂贵到最不昂贵...
// ...
return 0;
}
为了编写更简洁高效的比较方法,可以修改 RenderState
,使其将所有可排序状态存储在一个或多个位掩码中。每个位掩码在最高有效位存储最昂贵的状态,在最低有效位存储较不昂贵的状态。然后,比较方法中大量的单独渲染状态比较将被少数几个使用位掩码的比较所取代。这还可以显著减少 RenderState
使用的内存量,从而有助于缓存性能。
试一试:
修改RenderState
,使其可以使用位掩码进行状态排序。
Patrick 说:
Insight3D 使用的渲染状态通过位掩码存储状态,位顺序按照 Forsyth 的建议 [1] 进行排序。这具有占用内存少和启用简单高效排序函数的优点。不过,调试可能会很棘手,因为给定单个状态被压缩到位掩码中,当前渲染状态并不明显。使用这种设计时,值得编写调试代码以轻松可视化位掩码中的状态。
此外,按状态以外的其他因素排序也很常见。例如,通常先渲染所有不透明对象,再渲染所有半透明对象。不透明对象可以根据深度从近到远排序,以利用 Z 缓冲区优化,而半透明对象可以根据深度从远到近排序以实现正确的混合。状态排序与多遍渲染也不完全兼容,因为每遍都依赖于前一遍的结果,这使得重新排序绘制调用变得困难。
参考:
- Cozi, Patrick; Ring, Kevin. 3D Engine Design for Virtual Globes. CRC Press, 2011.
注释:
- https://tomforsyth1000.github.io/blog.wiki.html