LearnOpenGL Note - Advanced OpenGL - Blending

高级OpenGL-混合

Posted by Tao on Sunday, April 10, 2022

通过本节您可以了解到包括但不限于以下概念:

  • 什么是Alpha通道和混合技术?
  • OpenGL中怎么使用混合?
  • 全透明与半透明方法实现
  • 半透明的绘制顺序

1 Alpha通道与混合技术

Alpha通道是计算机中存储一张图片的透明和半透明度信息的通道。它是一个8位的灰度通道,用256级灰度来记录图像中的透明度信息,定义透明、不透明和半透明区域,其中黑表示全透明,白表示不透明,灰表示半透明。

OpenGL中,混合(Blending)通常是实现物体透明度(Transparency)的一种技术。透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。

alpha混合听上去很神秘,实际非常简单,其作用就是要实现一种半透明效果。假设一种不透明东西的颜色是A,另一种透明的东西的颜色是B,那么透过B去看A,看上去的颜色C就是B和A的混合颜色,可以用这个式子来近似,设B物体的透明度为alpha(取值为0-1,0为完全透明,1为完全不透明)

\[ \begin{equation} \begin{split} \color{red} R(C)=alpha *R(B)+(1-alpha)*R(A)\\ \color{green} G(C)=alpha *G(B)+(1-alpha)*G(A)\\ \color{blue} B(C)=alpha *B(B)+(1-alpha)*B(A) \end{split} \end{equation} \]

其中R(x)、G(x)、B(x)分别指颜色x的RGB分量(这里自变量x取的是颜色C)。

2 Discarding fragments 丢弃(全透明)

效果如透明玻璃。以小草贴图为例,我们不想费劲的创建草的几何模型,可以将草的纹理贴在一个矩形面上。然而,草的形状和2D四边形的形状并不完全相同,所以你只想显示小草纹理的某些部分,而忽略剩下的部分。

我们可以丢弃(Discard)显示纹理中透明部分的片段,不将这些片段存储到颜色缓冲中。

首先我们会创建一个vector,向里面添加几个glm::vec3变量来代表草的位置:

vector<glm::vec3> vegetation;
vegetation.push_back(glm::vec3(-1.5f,  0.0f, -0.48f));
vegetation.push_back(glm::vec3( 1.5f,  0.0f,  0.51f));
vegetation.push_back(glm::vec3( 0.0f,  0.0f,  0.7f));
vegetation.push_back(glm::vec3(-0.3f,  0.0f, -2.3f));
vegetation.push_back(glm::vec3( 0.5f,  0.0f, -0.6f));

每个草都被渲染到了一个四边形上,贴上了草的纹理。这并不能完美地表示3D的草,但这比加载复杂的模型要快多了。通过旋转面可以获得更好的效果(Billboard技术)。 因为草的纹理是添加到四边形几何上的,我们还需要创建另外一个VAO,填充VBO,设置正确的顶点属性指针。接下来,在绘制完地板和两个立方体后,我们将会绘制草:

glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);  
for(unsigned int i = 0; i < vegetation.size(); i++) 
{
    model = glm::mat4(1.0f);
    model = glm::translate(model, vegetation[i]);               
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

运行程序你将看到:

grass_result.png

出现这种情况是因为OpenGL默认是不知道怎么处理alpha值的,更不知道什么时候应该丢弃片段。GLSL给了我们discard命令,一旦被调用,它就会保证片段不会被进一步处理,所以就不会进入颜色缓冲。有了这个指令,我们就能够在片段着色器中检测一个片段的alpha值是否低于某个阈值,如果是的话,则丢弃这个片段,就好像它不存在一样:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

void main()
{             
    vec4 texColor = texture(texture1, TexCoords);
    if(texColor.a < 0.1)
        discard;
    FragColor = texColor;
}

这里,我们检测被采样的纹理颜色的alpha值是否低于0.1的阈值,如果是的话,则丢弃这个片段。片段着色器保证了它只会渲染不是(几乎)完全透明的片段。现在它看起来就正常了:

grass_right_result.png

注意,当采样纹理的边缘的时候,OpenGL会对边缘的值和纹理下一个重复的值进行插值(因为我们将它的环绕方式设置为了GL_REPEAT。这通常是没问题的,但是由于我们使用了透明值,纹理图像的顶部将会与底部边缘的纯色值进行插值。这样的结果是一个半透明的有色边框,你可能会看见它环绕着你的纹理四边形。要想避免这个,每当你alpha纹理的时候,请将纹理的环绕方式设置为GL_CLAMP_TO_EDGE

glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

你可以在这里找到源码。

3 Blending 混合(半透明)

丢弃片段可以产生透明效果,但不能让我们渲染半透明的图像。要想渲染有多个透明度级别的图像,我们需要启用混合(Blending)。要开启混色功能需要使用:

glEnable(GL_BLEND);

OpenGL中的混合是通过下面这个方程来实现的:

\[\begin{equation}\bar{C}_{result} = \bar{\textcolor{green}{C}}_{source} * \textcolor{green}{F}_{source} + \bar{\textcolor{red}{C}}_{destination} * \textcolor{red}{F}_{destination}\end{equation}\]

  • \(\bar{\textcolor{green}{C}}_{source}\):源颜色向量。这是源自纹理的颜色向量。
  • \(\bar{\textcolor{red}{C}}_{destination}\):目标颜色向量。这是当前储存在颜色缓冲中的颜色向量。
  • \(\textcolor{green}{F}_{source}\):源因子值。指定了alpha值对源颜色的影响。
  • \(\textcolor{red}{F}_{destination}\):目标因子值。指定了alpha值对目标颜色的影响。

片段着色器运行完成后,并且所有的测试都通过之后,这个混合方程(Blend Equation)才会应用到片段颜色输出与当前颜色缓冲中的值(当前片段之前储存的之前片段的颜色)上。源颜色和目标颜色将会由OpenGL自动设定,但源因子和目标因子的值可以由我们来决定。我们先来看一个简单的例子:

blending_equation.png

我们有两个方形,我们希望将这个半透明的绿色方形绘制在红色方形之上。红色的方形将会是目标颜色(所以它应该先在颜色缓冲中),我们将要在这个红色方形之上绘制这个绿色方形。

问题来了:我们将因子值设置为什么?好吧,我们至少要将绿色方块乘以其alpha值,因此我们要将\(F_{src}\)设置为源颜色向量的alpha值,即0.6。接下来就应该清楚了,目标方形的贡献应该为剩下的alpha值。如果绿色方形对最终颜色贡献了60%,那么红色方块应该对最终颜色贡献了40%,即1.0 - 0.6。所以我们将\(F_{destination}\)设置为1减去源颜色向量的alpha值。这个方程变成了:

\[\begin{equation}\bar{C}_{result} = \begin{pmatrix} \textcolor{red}{0.0} \\ \textcolor{green}{1.0} \\ \textcolor{blue}{0.0} \\ \textcolor{purple}{0.6} \end{pmatrix} * \textcolor{green}{0.6} + \begin{pmatrix} \textcolor{red}{1.0} \\ \textcolor{green}{0.0} \\ \textcolor{blue}{0.0} \\ \textcolor{purple}{1.0} \end{pmatrix} * \textcolor{red}{(1 - 0.6)} \end{equation}\]

blending_equation_mixed.png

最终的颜色将会被储存到颜色缓冲中,替代之前的颜色。

OpenGL提供了函数glBlendFunc用来设置上面的\(F_{src}\)和\(F_{destination}\),函数原型为:

API void glBlendFunc(GLenum sfactor, GLenum dfactor);

sfactor和dfactor用来指定源和目标颜色计算的系数,使用的是GL_ZERO, GL_ONE, GL_SRC_COLOR, GL_ONE_MINUS_SRC_COLOR等枚举值。

为了获得之前两个方形的混合结果,我们需要使用源颜色向量的alpha作为源因子,使用1−alpha作为目标因子。这将会产生以下的glBlendFunc:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

也可以使用glBlendFuncSeparate为RGB和alpha通道分别设置不同的选项:

glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);

API void glBlendFuncSeparate(GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha);

这里的参数同样是GL_ZERO,GL_ONE,GL_SRC_COLOR等枚举值。

OpenGL给了我们更多的灵活性,允许我们改变方程中源和目标部分的运算符。当前源和目标是相加的,但如果你愿意也可以让它们相减。glBlendEquation(GLenum mode)允许我们设置运算符,它提供了5个选项:

  • GL_FUNC_ADD:为默认选项,将两个分量相加:\(\bar{C}_{result} = \textcolor{green}{Src} + \textcolor{red}{Dst}\)
  • GL_FUNC_SUBTRACT:将两个分量相减:\(\bar{C}_{result} = \textcolor{green}{Src} - \textcolor{red}{Dst}\)
  • GL_FUNC_REVERSE_SUBTRACT:将两个分量相减,但顺序相反:\(\bar{C}_{result} = \textcolor{red}{Dst} - \textcolor{green}{Src}\)
  • GL_MIN:取两种颜色的各分量的最小值:\(\bar{C}_{result} = min(\textcolor{red}{Dst}, \textcolor{green}{Src})\)
  • GL_MAX:取两种颜色的各分量的最大值:\(\bar{C}_{result} = max(\textcolor{red}{Dst}, \textcolor{green}{Src})\)

通常我们都可以省略调用glBlendEquation,因为我们希望的混合方程一般来说都是GL_FUNC_ADD,但如果你真的不走寻常路,上述其它几个选项也可能符合你的要求。

一般我们使用的组合为:

glBlendEquation(GL_FUNC_ADD); // 默认,无需显式设置
glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);  

4 Rendering semi-transparent textures 渲染半透明

上面介绍了OpenGL中混色的计算,下面实现一个半透明的效果。 通过加载一个半透明的窗户到场景,使得透过窗户可以看到后面的场景。

首先,在初始化时我们启用混合,并设定相应的混合函数:

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

由于启用了混合,我们就不需要丢弃片段了,所以我们把片元着色器还原:

#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

void main()
{
    FragColor = texture(texture1, TexCoords);
}

现在(每当OpenGL渲染了一个片段时)它都会将当前片段的颜色和当前颜色缓冲中的片段颜色根据alpha值来进行混合(注意渲染顺序,以及后面会产生的问题)。由于窗户纹理的玻璃部分是半透明的,我们应该能通窗户中看到背后的场景了。

window_result.png

仔细观察会发现有问题:最前面窗户的透明部分遮蔽了背后的窗户。

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

所以我们只能禁用深度缓冲了,混合与深度缓冲不能同时使用。要想保证窗户中能够显示它们背后的窗户,我们需要首先绘制背后的这部分窗户。这也就是说在绘制的时候,我们必须先手动将窗户按照最远到最近来排序,再按照顺序渲染。

注意,对于草这种全透明的物体,我们可以选择丢弃透明的片段而不是混合它们,这样就解决了这些头疼的问题(没有深度问题)。

5 Don’t break the order 按顺序渲染

当绘制一个有不透明和透明物体的场景的时候,需要考虑排序问题(Transparency Sorting),大体的原则如下:

  1. 先绘制所有不透明的物体。
  2. 对所有透明的物体排序。
  3. 按顺序绘制所有透明的物体。

排序透明物体的一种方法是,从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得。接下来我们把距离和它对应的位置向量存储到一个STL库的map数据结构中。map会自动根据键值(Key)对它的值排序,所以只要我们添加了所有的位置,并以它的距离作为键,它们就会自动根据距离值排序了。

std::map<float, glm::vec3> sorted;
for (unsigned int i = 0; i < windows.size(); i++)
{
    float distance = glm::length(camera.Position - windows[i]);
    sorted[distance] = windows[i];
}

结果就是一个排序后的容器对象,它根据distance键值从低到高储存了每个窗户的位置。

之后,这次在渲染的时候,我们将以逆序(从远到近)从map中获取值,之后以正确的顺序绘制对应的窗户:

for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it) 
{
    model = glm::mat4();
    model = glm::translate(model, it->second);              
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, 6);
}

我们使用了map的一个反向迭代器(Reverse Iterator),反向遍历其中的条目,并将每个窗户四边形位移到对应的窗户位置上。这是排序透明物体的一个比较简单的实现,它能够修复之前的问题,现在场景看起来是这样的:

window_right_result.png

你可以在这里找到带有排序的完整源代码。

虽然按照距离排序物体这种方法对我们这个场景能够正常工作,但它并没有考虑旋转、缩放或者其它的变换,奇怪形状的物体需要一个不同的计量,而不是仅仅一个位置向量。

在场景中排序物体是一个很困难的技术,很大程度上由你场景的类型所决定,更别说它额外需要消耗的处理能力了。完整渲染一个包含不透明和透明物体的场景并不是那么容易。更高级的技术还有次序无关透明度(Order Independent Transparency, OIT),但这超出本教程的范围了。现在,你还是必须要普通地混合你的物体,但如果你很小心,并且知道目前方法的限制的话,你仍然能够获得一个比较不错的混合实现。

6 Reference

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus