从0开始的OpenGL学习(二十)-融混
本文主要解决一个问题:
如何在OpenGL中使用融混(Blending)操作?
引言
还记得前面的章节中我们使用两张纹理进行融合的事情吗?我们通过采样不同图片获得纹素,然后将纹素按照一定的比例混合起来显示。这样,看上去就有了两张图片混合的效果。但是,我们这里的融混(Blending)与之前的纹理图融合有区别,融混所要创造的是半透明和不透明同时存在的效果,而前面的融合操作会使整张纹理图都显得透明:
窗户的边框完全不透明,而窗户可能全透明,可能是半透明,这种效果就需要这章中要将的融混操作了。
Alpha分量
说到融混,就不得不提到颜色结构中的最后一个组成部分:alpha分量。alpha分量的意义就是透明度,值最小表示物体越透明,0.0表示物体全透明,1.0表示物体完全不透明。
到目前为止,我们使用纹理用到的都是其R、G、B三个分量,即便是有alpha值的图片,我们也无视alpha值。看一下下面这张图:
粉红色窗户
粉红色部分的alpha值为0.25,表示后面的颜色有75%可以透过,边框的alpha值为1.0,表示不透明,四个角的alpha值为0,表示完全透明。
丢弃片元
简单了解原理后,我们来尝试显示一些东西。我们知道,并不是所有的东西都能正好做成一张矩形图片的,想想野草:
野草
一张四四方方的图片上,有一大部分都是空白(这里的空白值的就是透明),只有下方的一小部分是野草的纹理。当我们想显示类似野草树木这样的纹理时,一个简单粗暴的方法是把透明部分的纹理都丢弃,只显示不透明的部分。
在显示纹理之前,我们先要修改一下加载图片的参数:将GL_RGB改成GL_RGBA,因为我们的图片需要有alpha值了。还是使用之前深度测试的代码,添加加载纹理图的功能,注意glTexImage2D的参数必须是:GL_RGBA
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);
而后,片元着色器中采样纹素时,要将所有的数据都采样出来,如果纹素的alpha值小于0.1,则丢弃:
vec4 texColor = texture(texture1, TexCoords);
if (texColor.a < 0.1)
discard;
FragColor = texColor;
要显示这些纹理,我们需要两个三角形的平面来承载,还需要一个专门的VAO来保存渲染环境,这些不多介绍,从之前的代码中拷贝修改即可。这里要说的是,我们的目的是显示6个草堆,为了之后的代码编写方便,要把所有的草堆位置都放到一个vector数组中:
std::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));
创建好专门的VAO之后,我们就可以来绘制了:
//草堆
glBindVertexArray(transparentVAO);
glBindTexture(GL_TEXTURE_2D, transparentTexture);
for (GLuint i = 0; i < vegetation.size(); i++) {
model = glm::mat4();
model = glm::translate(model, vegetation[i]);
shader.setMat4("model", glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 6);
}
代码修改完了,编译运行看看效果:
运行效果
非常完美,如果你的草堆图片边缘有一些白边,请把纹理环绕方式改成GL_CLAMP_TO_EDGE。因为我们之前设置的方式是GL_REPEAT,在平常状态下没有问题,但我们用的图边缘是透明的,这就需要在边缘采样边缘的纹素而不是图片开始的纹素(可能不是透明的)。
如果还是有问题,请参考这里的源码。
融混(Blending)
虽然直接把透明的片元丢弃是一个不错的方法,但是如果片元不完全透明呢?不能把它丢弃也不能完全显示。我们就需要开启融混(Blending)功能了。启动的方式和启动深度测试、模板测试一样,调用glEnable函数:
glEnable(GL_BLEND);
启动融混之后,我们需要告诉OpenGL如何进行融混。融混的公式如下:
融混公式
- Csrc:源颜色向量。从纹理中取出的颜色向量。
- Cdst:目标颜色向量。保存在颜色缓存中的颜色向量。
- Fsrc:源因子。表示从源颜色向量中取多少的颜色做融混用。
- Fdst:目标因子。表示从目标颜色向量中取多少颜色做融混用。
在片元着色器运行完,所有测试都通过之后,OpenGL就会自动设置源和目标颜色,然后根据融混因子计算出最终的颜色:
示例图
用上面这两张图来举例:红色为目标颜色,绿色为源颜色,绿色纹理具有透明属性,透明度40%,表示最终颜色受绿色影响度为60%。这样,源因子的值就是0.6,目标因子的值为0.4(如何设置源因子和目标因子下面会讲),根据公式,计算出最终的效果是(0.4, 0.6, 0.0, 0.76):
融混效果
融混之后的颜色会保存到颜色缓存中,替换之前的颜色。
融混设置
融混效果很不错,那么我们该如何设置这个融混因子呢?和之前的深度测试与模板测试类似,OpenGL也提供了一个函数来设置,这个函数就是:glBlendFunc。是不是感觉函数名都这么相似?哈哈,大概是OpenGL有意为之的。glBlendFunc函数的原型如下:
glBlendFunc(GLenum sfactor, GLenum dfactor)
sfactor和dfactor可以设置成OpenGL内部定义的枚举值,一般会设置成GL_SRC_ALPHA和GL_ONE_MINUS_SRC_ALPHA,你也可以把下面这张表里的项都试试:
选项 | 含义 |
---|---|
GL_ZERO | 因子值为0 |
GL_ONE | 因子值为1 |
GL_SRC_COLOR | 因子值等源颜色向量 |
GL_ONE_MINUS_SRC_COLOR | 因子值等于1-源颜色向量 |
GL_DST_COLOR | 因子值为目标颜色向量 |
GL_ONE_MINUS_SRC_COLOR | 因子值为1-目标颜色向量 |
GL_SRC_ALPHA | 因子值为源颜色的alpha分量 |
GL_ONE_MINUS_SRC_ALPHA | 因子值为1-源颜色的alpha分量 |
GL_DST_ALPHA | 因子值为目标颜色的alpha分量 |
GL_ONE_MINUS_DST_ALPHA | 因子值为1-目标颜色的alpha分量 |
GL_CONSTANT_COLOR | 因子值为一个颜色常量 |
GL_ONE_MINUS_CONSTANT_COLOR | 因子值为1-颜色常量 |
GL_CONSTANT_ALPHA | 因子值为颜色常量的alpha分量 |
GL_ONE_MINUS_CONSTANT_ALPHA | 因子值为1-颜色常量的alpha分量 |
需要注意的是,如果我们要用到颜色常量,不管是整个颜色还是其alpha分量,我们都可以使用,也都应该使用glBlendColor函数来设置颜色值。
要的到之前两个正方形的效果,我们把源因子设置成源颜色的alpha分量,把目标因子设置成1-alpha就可以了:
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
OpenGL给了我们非常大的灵活性,在融混操作上的体现就是我们可以设置对计算好的源和目标颜色做何种操作。默认情况下两个颜色是相加的,但只要我们愿意,我们完全可以设置成相减。glBlendEquation(GLenum mode)
函数就是OpenGL提供的设置接口,合法的参数如下所示:
- GL_FUNC_ADD:默认值,将两个颜色值相加。
- GL_FUNC_SUBTRACT:使用源颜色减去目标颜色。
- GL_FUNC_REVERSE_SUBSTRACT:很明显,是上一个参数的反操作,使用目标颜色减去源颜色。
绝大多数情况我们使用默认值就可以了,不过既然已经提供了这些接口,说明OpenGL也允许我们搞点事情,嘿嘿~
渲染半透明纹理
原理弄清楚了,是不是感觉很简单?笔者在学的时候也是这种感觉。该是把学到的知识运用到实践的时候。老实说,用起来也非常简单。我们把之前野草的图片替换成透明窗户,做两个小改动就可以了。
首先要做的自然是启用融混,然后将源因子设置成alpha,目标因子设置成1-alpha。
//开启融混
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
然后把片元着色器改会使用纹理采样的方式:
void main()
{
FragColor = texture(texture1, TexCoords);
}
好了,这下子OpenGL就会启用融混操作,显示出半透明的窗户效果了。赶紧编译运行,你会看到类似下面的效果:
运行效果不对啊,为什么前面窗户的角落地方不显示后面的窗户啊?
别急,这肯定是有原因的。出现这种情况是深度测试和融混操作的一些兼容性小问题。当我们显示了前面的窗户后,在进行深度测试时,后面的窗户必然不能通过测试,OpenGL就会把这些片元全部丢弃而没考虑到前面的片元是透明的,可以显示出后面的片元。
所以,我们不能无脑地将窗户都绘制出来,当然我们也不能无脑地关掉深度测试。要保证能够得到正确的效果,一种方法就是将窗户从后往前逐个绘制,先绘制后面的窗户,再绘制前面的窗户。这就要求我们把这些窗户根据距离观察者的远近进行排序。
排序
要进行排序,首先要计算与观察者之间的距离,观察者的位置是动态的,所以我们必须每次渲染都计算一遍距离。至于排序的操作,我们可以耍点小技巧,就用标准库中的map容器,它默认是从小到大排序的。将这些思路转换成代码就是:
std::map<float, glm::vec3> sorted;
for (int i = 0; i < vegetation.size(); ++i) {
float distance = glm::length(camera.Position - vegetation[i]);
sorted[distance] = vegetation[i];
}
排序完成后,接下来的事情就简单了,只要遍历sorted容器中的位置,逐个绘制就可以了:
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", glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 6);
}
我们从map中最后一个开始绘制,直到第一个,因为越是后面的距离越远。编译运行:
运行效果
效果完美!多转转看看前后的效果是不是也正确的。如果你遇到了困难,请下载这里的源码比对。
最后,来泼一盆冷水。事实上,我们实施的将物体按照距离排序的方案还是太过简单化了,没考虑到旋转、比例变换、物体的形状等等一系列的东西。而且,对物体进行排序是一项艰难的壮举,取决于你渲染的是什么样的场景,更不用说还有额外的CPU资源消耗。完整地渲染一个有实体有透明物体的场景从来都不是一个简单的事情,前辈们也研究了一些更高级的技术,例如次序无关透明(order independent transparency),这就不在这篇文章的讨论范围之内了,有兴趣的童鞋可以去搜一下。现在,我们就用这种方式来渲染场景就好!
总结
本章学习的东西非常简单,第一个是如何丢弃一个片源,使用discard关键字就可以做到。第二是如何使用融混,启用融混,设置融混因子就可以达到目的。第三是融混操作公式,这是非常重要的,再默写一遍Cresult = Csrc * Fsrc + Cdst * Fdst。非常好~
参考资料
www.learnopengl.com(很好的网站,建议学习)