OpenGL Fixed Pipeline

OpenGL中的固定管线函数

OpenGL的pipeline中,有像 vertex shadergeometry shaderfragment shader 等的可编程管线,也有像depth test(z-buffering)stencil testalpha test 等由Host控制状态,GPU自动处理的渲染过程。我们这里主要介绍 fragment shader 之后的固定管线函数及其原理。

计算机图形学的目的就是计算出一副图像的颜色值

OpenGL Buffer Overview

几乎所有图形程序都共有一个目标,就是在屏幕上绘制图像(或者绘制到离屏的一处帧缓冲中)。

帧缓冲(通常是屏幕)是由矩形的像素数组组成的,每个像素都可以在图像对应的点上显示一小方块的颜色值。经过光栅化阶段和执行片元着色器之后,得到的数据还不是真正的像素–只是候选的片元。每个片元都包含于像素位置对应的坐标数据,以及颜色和深度的存储值。通常OpenGL包含如下几种缓冲:

  1. 一个或多个color buffer(多渲染目标Multiple Render Targets,G缓冲)
  2. 深度缓冲
  3. 模板缓冲

OpenGL管线中,在顶点着色之后要进行的操作是光栅化(rasterization)。它主要会判断屏幕空间的哪个部分被几何体覆盖,线性化插值(倒数)顶点属性,比如 Gouraud 着色等等(即 Fragment Shader 中输入的属性不是 Vertex Shader 中传出的属性,而是属性的线性插值)。光栅化相当于一个片元生命的开始,而Fragment Shader的本质相当于计算这个片元最终的颜色。后文将会介绍管线中对各个片元的测试和操作(又叫Per-Sample Processing),它们将真正决定一个片元最终能否成为帧缓冲中的一个像素。

Pixel Ownership Test

默认缓冲对于OpenGL而言属于外部资源,所以对于默认缓冲上的某些像素,它们可能不属于OpenGL,所以 OpenGL 不能写入这些像素。Pixel OwnerShip Test 就是为了检测这样的像素并且在pipeline 中将其丢弃。
通常来说,如果渲染用的图形窗口被另一窗口所遮挡,亦被遮挡的像素不再属于OpenGL,这部分像素会被丢弃因为它们通不过像素所有权测试。

Pixel Ownership Test仅仅影响默认帧缓冲。对于Framebuffer对象没有任何影响,其中所有的像素都会通过测试

Scissor Test

Scissor Test 是片元可见性判断的第一个附加测试。我们将程序窗口的一个矩形区域称作一个剪裁盒,并且将所有的绘制操作都限制在这个区域内。我们可以用glScissor命令来设置这个剪切盒,并且使用glEnable来开启测试。具体代码如下:

glEnable(GL_SCISSOR_TEST);
glScissor( x, y, width, height );
// rendering loop...

如果开启测试,那么所有的渲染、清除等都被限制在剪切和区域内。通常,我们可以使用 Scissor Test 操作来做 Picture-in-picture 操作,大概的代码流程如下:

// rendering loop
glViewport( 0, 0, SCR_WIDTH, SCR_HEIGHT );
render(); // render normal

// rendering 
glViewport( x, y, width, height );
glEnable(GL_SCISSOR_TEST);
glScissor( x, y, width, height );
render(); // render Picture-in-picture
glDisable(GL_SCISSOR_TEST);

这里要注意的是,屏幕坐标从左下角开始(即0,0)

Stencil Test

模板测试本质上就是设定一个Mask,将 fragment 和mask做比较,如果符合条件则保留 fragment,反之则丢弃。一个模板缓冲中,(通常)每个模板值(Stencil Value)是8位的。所以每个像素/片段一共能有256(0xFF)种不同的模板值。我们可以将这些模板值设置为我们想要的值,然后当某一个片段有某一个模板值的时候,我们就可以选择丢弃或是保留这个片段了。这里我们用LearnOpenGL中的例子来说明:

Stencil Buffer的使用

首先我们需要清空模板缓冲(此时的值为0):

glClear(GL_STENCIL_BUFFER_BIT);

接着,模板缓冲允许我们在渲染片段时将模板设定为一个特定的值。通过在渲染时修改模板缓冲的内容,我们写入模板缓冲。在同一帧的渲染中,我们可以读取这些值来决定丢弃还是保留某个片段。大体的过程如下:

  • 启动模板缓冲: glEnable(GL_STENCIL_TEST)
  • 清空模板缓冲: glClear(GL_STENCIL_BUFFER_BIT)
  • 设置模板缓冲更新函数:

    // func -- GL_ALWAYS, GL_NEVER...
    // ref -- Reference Value, stencil buffer will compare with this value
    // mask -- AND operation before comparing with ref 
    glStencilFunc(GLenum func, GLint ref, GLuint mask)
    
  • 渲染物体(可以理解为Mask),更新模板缓冲的内容
  • 禁止模板缓冲的写入: glStencilMask(0x00)
  • 渲染其他物体,这次根据模板缓冲的内容丢弃特定的片段

模板测试的应用-提取物体轮廓

同样我们从LearnOpenGL中选取了物体轮廓的例子来讲述如何使用模板测试。我们可以给某个物体在他周围创建一个很小的有色边框,步骤如下:

  1. 在绘制(需要添加轮廓的)物体之前,将模板函数设置为GL_ALWAYS,每当物体的片段被渲染时,将模板缓冲更新为1。
  2. 设置 glStencilMask(0x00),绘制不希望写入模板缓存的物体,比如地板,天空盒
  3. 渲染物体
  4. 禁用模板写入以及深度测试
  5. 将物体放大一点
  6. 使用一个不同的fragment shader,其作用是输出一个单独的边框颜色
  7. 渲染物体,但是只有fragment的模板值不等于1时才绘制
  8. 恢复上下文中的模板和深度状态

Alpha Test

Alpha Test 就是测试每一个像素的Alpha值是否满足某一个特定的条件,如果满足,则该像素会被绘制,不满足则丢弃。早期OpenGL中经常用:

glEnable( GL_ALPHA_TEST );
glAlphaFunc( GL_GREATER, 0.1f );
// rendering
glDisable( GL_ALPHA_TEST );

Modern OpenGL 中,可以在fragment shader中直接根据alpha的值丢弃fragment:

// glsl 
#version 330 core
in vec2 TexCoords;
out vec4 FragColor;
uniform texture2D tex;
uniform float alpha;
void main() {
    vec4 color = texture( tex, TexCoords );
    if ( color.a < alpha ) {
        discard;
    }
    FragColor = color;
}

借用LearnOpenGL Blend中的一个例子,我们可以看到如下结果:

Depth Test

对于屏幕上的每个pixel而言,深度缓存都会记录场景中物体与视点在这个像素上的距离信息。如果输入的深度值可以通过指定的深度测试环节,那么它就可以替换当前的深度缓存中已有的深度值,新像素值会取代原先的;如果没有通过深度测试,则丢弃当前fragment。下图就是使用深度测试后的效果:

深度缓冲的创建

深度缓冲是由窗口系统自动创建的,它会以16、24或32位float的形式存储他的深度值,大部分系统的深度缓冲精度都是24位,在fragment shader中,其值的范围在0.0-1.0之间。

深度缓冲的使用

深度测试默认是禁用的,通过GL_DEPTH_TEST可以启用它:

glEnable(GL_DEPTH_TEST);

如果启用了深度测试,那么在每次渲染的时候都需要清空深度缓冲(每次渲染迭代,渲染到framebuffer),我们可以使用GL_DEPTH_BUFFER_BIT来清空深度缓冲:

glClear(GL_DEPTH_BUFFER_BIT);

深度测试函数

OpenGL允许我们修改深度测试中使用的比较运算符,这允许我们来控制OpenGL什么时候该通过或丢弃一个片段,什么时候去更新深度缓冲。我们可以调用glDepthFunc来设置比较运算符:

glDepthFunc(GL_LESS);

OpenGL默认使用GL_LESS作为比较运算符,还有诸如GL_AWALYS,GL_NEVER等方式。下面是我们将运算符设置为GL_ALWAYS时的输出效果图。

深度值的访问

GLSL中,我们可以通过gl_FragCoord从片段着色器中直接访问。gl_FragCoord的x和y分量代表了片段的屏幕空间坐标(其中(0, 0)位于左下角)。gl_FragCoord中也包含了一个z分量,它包含了片段真正的深度值。z值就是需要与深度缓冲内容所对比的那个值。

深度值的数学原理

如果对投影变换有所了解的话,就会知道顶点属性是坐标值倒数的线性插值。那么通过一系列的透视投影变换以后,我们可以得倒Normal Device Coordinates下深度值的表达式:

$$
\begin{equation}\tag{1}
z = -\frac{2near \cdot far}{far-near}\frac{1}{P_{z}} + \frac{far+near}{far-near}
\end{equation}
$$

其中\(P_{z}\)表示当前点P的z值。

我们将\(z’=2Depth_{frag}+1\)带入上式中,得:

$$
\begin{equation}\tag{2}
F_{depth} = \frac{1/Depth_{frag} - 1/near}{1/far - 1/near}
\end{equation}
$$

同样的,在fragment shader中,我们已知gl_FragCoord,那么就可以通过下式求出对应的NDC坐标了:

$$
\begin{equation}\tag{3}
z = \frac{2near \cdot far}{far+near-gl\_FragCoord.z \cdot (far-near)}
\end{equation}
$$

深度缓冲的可视化

我们可以根据fragment的深度值返回一个颜色向量来完成深度缓冲的可视化:

void main() {
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}

fragment shader中我们可以描述其为:

#version 330 core

out vec4 FragColor;

uniform float near;
uniform float far;

float LinearizeDepth( float depth ) {
    return (2.0 * near * far) / (far + near - z * (far - near));
}

void main() {
    float depth = LinearizeDepth(gl_FragCoord.z);
    FragColor = vec4(vec3(depth), 1.0);
}

Z-fighting

一个很常见的视觉错误会在两个平面或者三角形非常紧密地平行排列在一起时会发生,深度缓冲没有足够的精度来决定两个形状哪个在前面。结果就是这两个形状不断地在切换前后顺序,这会导致很奇怪的花纹。这个现象叫做深度冲突(Z-fighting),因为它看起来像是这两个形状在争夺(Fight)谁该处于顶端。防止z-fighting技巧有很多:

  • 不要把物体放的太近
  • 将近平面设的远一些
  • 使用更高精度的深度缓冲(GL_DEPTH COMPONENT32F)
  • Polygon offset

Polygon Offset

Polygon offset用于解决线和填充多边形的光栅化过程不一致导致的 stitching 问题,也可以解决 Z-fighting 问题。
启动多边形偏移的方法有三种,分别对应三种不同的光栅化方式:GL_FILLGL_LINEG:_POINT。通常我们的做法是:

  • glEnable()传入对应的参数来开启多边形偏移,即GL_POLYGON_OFFSET_FILLGL_POLYGON_OFFSET_LINEGL_POLYGON_OFFSET_POINT
  • 接着在每个渲染之前,使用glPolygonOffset给当前对象设置一个深度偏移

    // offset = m*factor + r * units
    // m - max depth slope
    // r - min acceptable depth difference 
    // factor can be set (1.0, 1.0) ...
    void glPolygonOffset(GLfloat factor, GLfloat units);
    
  • 渲染对象
  • 设置下一个要渲染对象的深度偏移,重复上述操作

代码如下:

// opengl setting
glEnable( GL_DEPTH_TEST );
glEnable( GL_POLYGON_OFFSET_FILL );

// rendering loop
while ( !glfwWindowShouldClose(window) ) {
    // pre-process ...

    //
    glm::mat4 view = camera.getViewMatrix();
    glm::mat4 projection = glm::perspective( glm::radians(camera.Zoom), (float)SCR_WIDTH / SCR_HEIGHT, 0.1f, 100.0f );

    // rendering
    shader.use();
    shader.setMat4( "view", glm::value_ptr(view) );
    shader.setMat4( "projection", glm::value_ptr(projection) );

    // 1st
    glm::mat4 model(1);
    model = glm::translate( model, glm::vec3( 1.0f, 0.0f, 0.0f ) );
    shader.setMat4( "model", glm::value_ptr(model) );
    shader.setVec3( "color", 1.0f, 0.0f, 0.0f );
    glPolygonOffset( -1.0, -1.0 );
    renderSquard();

    // 2nd
    model = glm::mat4(1);
    model = glm::translate( model, glm::vec3( -1.0f, 0.0f, 0.0f ) );
    shader.setMat4( "model", glm::value_ptr(model) );
    shader.setVec3( "color", 0.0f, 1.0f, 0.0f );
    glPolygonOffset( -2.0, -2.0 );
    renderSquard();

    // 3rd
    model = glm::mat4(1);
    model = glm::translate( model, glm::vec3( 0.0f, 1.0f, 0.0f ) );
    shader.setMat4( "model", glm::value_ptr(model) );
    shader.setVec3( "color", 0.0f, 0.0f, 1.0f );
    glPolygonOffset( -3.0, -3.0 );
    renderSquard();

    // post-process...
}

Without Polygon offset
With Polygon Offset

Disable and Enable polygon offset

Alpha Blend

如果一个fragment通过了所有的测试,那么它就可以和颜色缓存中当前的内容进行合并了。最简单的自然是覆盖之前的颜色。除此之外,我们还可以用混合,将当前fragment和color buffer中的像素进行alpha的融合,产生出一种透明的效果。OpenGL中混合是通过如下公式实现的:

$$
\begin{equation}\tag{4}
Color_{result} = Color_{source} \cdot F_{source} + Color_{destination} \cdot F_{destination}
\end{equation}
$$

  • \( F_{source}\):源因子值,即alpha值对color buffer当前像素的影响
  • \( F_{destination}\):源因子值,即alpha值对fragment的影响

为了实现上述方程,OpenGL为我们提供了glBlendFunc

// sfactor, dfactor: GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_DST_ALPHA, GL_ONE_MINUS_DST_ALPHA...
glBlendFunc(GLenum sfactor, GLenum dfactor);

通常,我们只用在初始化的时候开启混合:

glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

开启混合之后,如果我们直接绘制场景,那么会得到如下效果:

可以发现,透明的部分遮挡了背后的窗户。原因是,深度测试和混合一起使用的话会产生一些麻烦。当写入深度缓冲时,深度缓冲不会检查片段是否是透明的,所以透明的部分会和其它值一样写入到深度缓冲中。结果就是窗户的整个四边形不论透明度都会进行深度测试。即使透明的部分应该显示背后的窗户,深度测试仍然丢弃了它们,LearnOpenGL Blend

为了能够正确的使用混合,保证先绘制最远的物体,最后绘制最近的物体,我们需要对深度进行排序。排序透明物体的一种方法是,从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得LearnOpenGL Blend。最终我们可以得下图:

Dithering

对于颜色位数目比较小的系统来说,我们可以通过对图像中的颜色进行抖动(dithering)来提升颜色的分辨率(代价是损失一部分空间分辨率–像素有偏移)。对于颜色位数较小的系统,我们可以将临近像素的红色、绿色和蓝色进行抖动,以模拟更大范围的色彩变化。
dithering 本身是和硬件相关的。OpenGL 能做的只是开启或关闭这个特性。事实上,在某些颜色分辨率非常高的机器上,如retina,那么开启抖动可能不会产生任何的效果,默认情况下dithering 是开启的。

glEnable( GL_DITHER );
glDisable( GL_DITHER );

Reference

  1. OpenGL Pixel Ownership Test
  2. LearnOpenGL 深度测试
  3. OpenGL中真正的深度值-z-buffer探究
  4. LearnOpenGL 模板测试
0%