一、简介
本文介绍了如何使用OpenGL实现透明效果(transparent),并在最后给出了全部的代码。
在实现透明效果时,使用OpenGL中的混合(Blend)功能,根据纹理贴图的 alpha 分量将各像素(片段)对应的多个颜色值进行混合。
按照本文代码实现完成后,理论上可以得到如下结果:
二、混合 Blend
0. Blend 是什么
在 OpenGL 中,Blend 功能指的是在渲染片段时,可以根据一定的自定义规则(Blend Function)将
将当前渲染的颜色(源颜色,source color)与目标缓冲区,通常是帧缓冲区(目标颜色,destination color)中已有的颜色进行合并的技术。其计算公式如下:
C
r
e
s
u
l
t
=
C
s
o
u
r
c
e
∗
F
s
o
u
r
c
e
+
C
d
e
s
t
i
n
a
t
i
o
n
∗
F
d
e
s
t
i
n
a
t
i
o
n
C_{result} = C_{source} * F_{source} + C_{destination} * F_{destination}
Cresult=Csource∗Fsource+Cdestination∗Fdestination
其中:
C
r
e
s
u
l
t
C_{result}
Cresult 是混合后的结果,跟
C
d
e
s
t
i
n
a
t
i
o
n
C_{destination}
Cdestination存储在相同的位置;
C
s
o
u
r
c
e
C_{source}
Csource是源颜色向量,指源自纹理的颜色向量。
C
d
e
s
t
i
n
a
t
i
o
n
C_{destination}
Cdestination是目标颜色向量。指当前储存在目标帧缓冲中的颜色向量;
F
s
o
u
r
c
e
F_{source}
Fsource是源因子值。指定了纹理alpha分量值对源颜色的影响;
F
d
e
s
t
i
n
a
t
i
o
n
F_{destination}
Fdestination是目标因子值。指定了当前储存在目标帧缓冲中的alpha值对目标颜色的影响;
1. 启用 GL_BLEND
OpenGL 中默认没有启用 Blend,因此要想渲染有多个透明度级别的图像,我们需要在 OpenGL 中启用混合(Blending)。和OpenGL大多数的功能一样,我们可以启用 GL_BLEND 来启用混合:
glEnable(GL_BLEND);
2. 设置 Blend Function
我们可以使用
glBlendFunc(GLenum sfactor, GLenum dfactor)
函数设置 Blend Function,指定如何根据源自纹理颜色向量中的 alpha 分量混合
C
s
o
u
r
c
e
C_{source}
Csource 和
C
d
e
s
t
i
n
a
t
i
o
n
C_{destination}
Cdestination。
本文使用
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
设置混合方程。即, C r e s u l t = C s o u r c e ∗ a l p h a s o u r c e + C d e s t i n a t i o n ∗ ( 1.0 − a l p h a s o u r c e ) C_{result} = C_{source} * alpha_{source} + C_{destination} * (1.0-alpha_{source}) Cresult=Csource∗alphasource+Cdestination∗(1.0−alphasource)。
三、使用OpenGL实现透明效果
0. 环境需要
- Linux,或者 windos下使用wsl2。
- 安装GLFW和GLAD。请参考[OpenGL] wsl2上安装使用cmake+OpenGL教程。
- 安装glm。glm是个可以只使用头文件的库,因此可以直接下载release的压缩文件,然后解压到
include
目录下。例如,假设下载的release版本的压缩文件为glm-1.0.1-light.zip
。将glm-1.0.1-light.zip
复制include
目录下,然后执行以下命令即可解压glm源代码:unzip glm-1.0.1-light.zip
- 需要使用
Assimp
库加载obj模型,在 ubuntu 下可以使用以下命令安装Assimp
:sudo apt-get update sudo apt-get install libassimp-dev
- 需要下载 stb_image.h 作为加载
.png
图像的库。将 stb_image.h 下载后放入include/
目录下。
1. 项目目录
其中:
Mesh.hpp
包含了自定义的 Vertex, Texture, 和 Mesh 类。Model.hpp
包含了自定义的Model类,用于加载obj模型。一个Model可以包含多个Mesh。在加载obj模型时使用Assimp库加载。还包含一个用于加载纹理的函数TextureFromFile()
。Shader.hpp
用于创建 shader 程序。Blinn-Phong.vert
和Blinn-Phong.frag
是使用Blinn-Phong光照模型渲染场景
的 顶点着色器 和 片段着色器 代码。transparent.vert
和transparent.frag
是用于渲染透明物体
的 顶点着色器 和 片段着色器 代码。该 shader 只需要将指定的纹理加载到模型上即可,无需根据光照模型对颜色进行处理。
下面介绍各部分主要的代码:
2. CMakeLists.txt代码
cmake_minimum_required(VERSION 3.10)
set(CMAKE_CXX_STANDARD 14)
project(OpenGL_Blending)
include_directories(include)
find_package(glfw3 REQUIRED)
find_package(assimp REQUIRED)
file(GLOB project_file main.cpp glad.c)
add_executable(${PROJECT_NAME} ${project_file})
target_link_libraries(${PROJECT_NAME} glfw assimp)
3. Model.hpp和Mesh.hpp 代码
Model.hpp 和 Mesh.hpp 代码与 LearnOpenGL-模型加载-模型 中的代码类似,使用Assimp库,基于递归的方式加载模型和纹理。读者可以参考LearnOpenGL-模型加载-模型。
对 Model.hpp 中加载纹理的函数TextureFromFile()
进行了修改,修改后的函数为:
unsigned int TextureFromFile(const char *path, const string &directory, bool gamma = false,
GLuint wrap_type = GL_REPEAT);
...
unsigned int TextureFromFile(const char *path, const string &directory, bool gamma, GLuint wrap_type)
{
string filename = string(path);
filename = directory + '/' + filename;
unsigned int textureID;
glGenTextures(1, &textureID);
int width, height, nrComponents;
unsigned char *data = stbi_load(filename.c_str(), &width, &height, &nrComponents, 0);
if (data)
{
GLenum format;
if (nrComponents == 1)
format = GL_RED;
else if (nrComponents == 3)
format = GL_RGB;
else if (nrComponents == 4)
format = GL_RGBA;
glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap_type);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap_type);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
}
else
{
std::cout << "Texture failed to load at path: " << path << std::endl;
stbi_image_free(data);
}
return textureID;
}
在函数TextureFromFile(...)
中增加了指定纹理wrap
方式的参数。这是因为对于普通的纹理我们可以使用GL_REPEAT
方式,将纹理坐标为 [0,1] 之外的纹理使用重复的方式填充。但是对于具有透明效果的纹理如果依旧使用 重复
效果填充,可能会出现透明处理错误的结果,因此对于具有 透明效果的纹理,我们选择使用GL_CLAMP_TO_EDGE
方式,这样可以将纹理坐标在 [0,1] 之外的采样强制 clamp 到 0 或者 1,保证了透明效果的正确性。
本文最后给出了全部的代码,可以下载运行使用。
4. Blinn-Phong shader 代码
渲染场景的 Blinn-Phong shader 使用Blinn-Phong模型渲染场景。
Blinn-Phong shader的顶点着色器和片段着色器代码:
Blinn-Phong.vert
:
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNor;
layout(location = 2) in vec2 aTexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec3 vertexPos;
out vec3 vertexNor;
out vec2 textureCoord;
void main() {
textureCoord = aTexCoord;
// 裁剪空间坐标系 (clip space) 中 点的位置
gl_Position = projection * view * model * vec4(aPos, 1.0f);
// 世界坐标系 (world space) 中 点的位置
vertexPos = (model * vec4(aPos, 1.0f)).xyz;
// 世界坐标系 (world space) 中 点的法向
vertexNor = mat3(transpose(inverse(model))) * aNor;
}
Blinn-Phong.frag
:
#version 330 core
out vec4 FragColor;
in vec3 vertexPos;
in vec3 vertexNor;
in vec2 textureCoord;
uniform vec3 cameraPos;
uniform vec3 lightPos;
uniform vec3 k;
uniform sampler2D texture0;
void main() {
vec3 lightColor = vec3(1.0f, 1.0f, 1.0f);
// Ambient
// Ia = ka * La
float ambientStrenth = k[0];
vec3 ambient = ambientStrenth * lightColor;
// Diffuse
// Id = kd * max(0, normal dot light) * Ld
float diffuseStrenth = k[1];
vec3 normalDir = normalize(vertexNor);
vec3 lightDir = normalize(lightPos - vertexPos);
vec3 diffuse =
diffuseStrenth * max(dot(normalDir, lightDir), 0.0) * lightColor;
// Specular (Phong)
// Is = ks * (view dot reflect)^s * Ls
// float specularStrenth = k[2];
// vec3 viewDir = normalize(cameraPos - vertexPos);
// vec3 reflectDir = reflect(-lightDir, normalDir);
// vec3 specular = specularStrenth *
// pow(max(dot(viewDir, reflectDir), 0.0f), 2) * lightColor;
// Specular (Blinn-Phong)
// Is = ks * (normal dot halfway)^s Ls
float specularStrenth = k[2];
vec3 viewDir = normalize(cameraPos - vertexPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
vec3 specular = specularStrenth *
pow(max(dot(normalDir, halfwayDir), 0.0f), 2) * lightColor;
// Obejct color
vec3 objectColor = vec3(0.8, 0.8, 0.8);
if (textureCoord.x >= 0 && textureCoord.y >= 0) {
objectColor = texture(texture0, textureCoord).xyz;
}
FragColor = vec4((ambient + diffuse + specular) * objectColor, 1.0f);
}
5. transparent shader 代码
transparent shader 用于渲染具有透明纹理的模型。在该 shader 中只需要设置各顶点的 position,以及加载纹理即可。
顶点着色器的代码如下:
transparent.vert
:
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNor;
layout(location = 2) in vec2 aTexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec2 textureCoord;
void main() {
// 裁剪空间坐标系 (clip space) 中 点的位置
gl_Position = projection * view * model * vec4(aPos, 1.0f);
// 纹理坐标
textureCoord = aTexCoord;
}
片段着色器如下
transparent.frag
:
#version 330 core
out vec4 FragColor;
in vec2 textureCoord;
uniform sampler2D texture0;
void main() {
FragColor = texture(texture0, textureCoord);
}
6. main.cpp 代码
6.1). 代码整体流程
- 初始化glfw,glad,窗口
- 编译 shader 程序
- 加载obj模型、纹理图片、透明物体模型
- 设置光源和相机位置,Blinn-Phong 模型参数
- 开始渲染
5.1 使用 blinnPhongShader 渲染不透明的物体
5.2 使用 transShader 渲染透明的物体 - 释放资源
6.2). main.cpp代码
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include "Skybox.hpp"
#include "Shader.hpp"
#include "Mesh.hpp"
#include "Model.hpp"
#include "glm/ext.hpp"
#include "glm/mat4x4.hpp"
#include <random>
#include <iostream>
// 用于处理窗口大小改变的回调函数
void framebuffer_size_callback(GLFWwindow *window, int width, int height);
// 用于处理用户输入的函数
void processInput(GLFWwindow *window);
// 指定窗口默认width和height像素大小
unsigned int SCR_WIDTH = 800;
unsigned int SCR_HEIGHT = 600;
/************************************/
int main()
{
/****** 1.初始化glfw, glad, 窗口 *******/
// glfw 初始化 + 配置 glfw 参数
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
// 在创建窗口之前
glfwWindowHint(GLFW_SAMPLES, 4); // 设置多重采样级别为4
// glfw 生成窗口
GLFWwindow *window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
// 检查是否成功生成窗口,如果没有成功打印出错信息并且退出
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
// 设置窗口window的上下文
glfwMakeContextCurrent(window);
// 配置window变化时的回调函数
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
// 使用 glad 加载 OpenGL 中的各种函数
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// 启用 深度测试
glEnable(GL_DEPTH_TEST);
// 启用 多重采样抗锯齿
glEnable(GL_MULTISAMPLE);
// 启用 混合
glEnable(GL_BLEND);
// 源: 片段着色器中处理的片段; 目标: 颜色缓冲
// 使用 源颜色alpha 作为 源因子, (1-源颜色alpha) 作为 目标因子
// 那么颜色缓冲中最终的颜色为 C_color_buffer = S_alpha * C_s + (1.0 - S_alpha) * C_color_buffer
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); // 使用线框模式,绘制时只绘制 三角形 的轮廓
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); // 使用填充模式,绘制时对 三角形 内部进行填充
/************************************/
/****** 2.编译 shader 程序 ******/
// 使用Blinn-Phong模型渲染场景的 shader
Shader blinnPhongShader("../resources/Blinn-Phong.vert", "../resources/Blinn-Phong.frag");
// 渲染 透明物体的 shader
Shader transparentShader("../resources/transparent.vert", "../resources/transparent.frag");
/************************************/
/****** 3.加载obj模型、纹理图片、透明物体模型 ******/
// scene mesh
Model ourModel("../resources/models/spot/spot.obj");
// Model ourModel("../resources/models/nanosuit/nanosuit.obj");
// 透明的窗户 0
vector<Vertex> transWinVertex0 = {
{{-0.80, 0.20, -1.0}, {0.0, 0.0, 1.0}, {0.0, 1.0}}, // position, normal, texture_coordinate
{{-0.80, -0.80, -1.0}, {0.0, 0.0, 1.0}, {0.0, 0.0}}, //
{{0.20, -0.80, -1.0}, {0.0, 0.0, 1.0}, {1.0, 0.0}}, //
{{0.20, 0.20, -1.0}, {0.0, 0.0, 1.0}, {1.0, 1.0}}};
vector<unsigned int> transWinIndex0 = {0, 1, 2, 2, 3, 0};
vector<Texture> transWinTexture0 = {{TextureFromFile("window.png", "../resources/textures", true, GL_CLAMP_TO_EDGE),
"texture_diffuse",
"../resources/textures/window.png"}}; // textire_id, type, file_path
Mesh transWinMesh0(transWinVertex0, transWinIndex0, transWinTexture0);
Model transWinModel0(transWinMesh0);
// 透明的窗户 1
vector<Vertex> transWinVertex1 = {
{{-0.10, 0.90, -1.2}, {0.0, 0.0, 1.0}, {0.0, 1.0}}, // position, normal, texture_coordinate
{{-0.10, -0.10, -1.2}, {0.0, 0.0, 1.0}, {0.0, 0.0}}, //
{{0.90, -0.10, -1.2}, {0.0, 0.0, 1.0}, {1.0, 0.0}}, //
{{0.90, 0.90, -1.2}, {0.0, 0.0, 1.0}, {1.0, 1.0}}};
vector<unsigned int> transWinIndex1 = {0, 1, 2, 2, 3, 0};
vector<Texture> transWinTexture1 = {{TextureFromFile("window.png", "../resources/textures", true, GL_CLAMP_TO_EDGE),
"texture_diffuse",
"../resources/textures/window.png"}}; // textire_id, type, file_path
Mesh transWinMesh1(transWinVertex1, transWinIndex1, transWinTexture1);
Model transWinModel1(transWinMesh1);
/************************************/
/****** 4.设置光源和相机位置,Blinn-Phong 模型参数 ******/
// I = Ia + Id + Is
// Ia = ka * La
// Id = kd * (normal dot light) * Ld
// Is = ks * (reflect dot view)^s * Ls
// 模型参数 ka, kd, ks
float k[] = {0.1f, 0.7f, 0.2f}; // ka, kd, ks
// 光源位置
glm::vec3 light_pos = glm::vec3(-2.0f, 2.0f, 0.0f);
// 相机位置
glm::vec3 camera_pos = glm::vec3(0.0f, 0.0f, 1.5f);
/************************************/
/****** 5.开始渲染 ******/
float rotate = 180.0f;
while (!glfwWindowShouldClose(window))
{
// rotate += 0.05f;
// input
// -----
processInput(window);
// render
// ------
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
// 清除颜色缓冲区 并且 清除深度缓冲区
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 5.1 使用 blinnPhongShader 渲染不透明的物体
blinnPhongShader.use();
// 设置 camera_MVP 矩阵, 假设以 camera 为视角,渲染 camera 视角下的场景深度图
// camera model 矩阵
glm::mat4 camera_model = glm::mat4(1.0f);
camera_model = glm::translate(camera_model, glm::vec3(0.0f, 0.0f, 0.0f));
camera_model = glm::rotate(camera_model, glm::radians(0.0f), glm::vec3(1.0f, 0.0f, 0.0f));
camera_model = glm::rotate(camera_model, glm::radians(rotate), glm::vec3(0.0f, 1.0f, 0.0f));
camera_model = glm::rotate(camera_model, glm::radians(0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
camera_model = glm::scale(camera_model, glm::vec3(0.5f, 0.5f, 0.5f));
// camera view 矩阵
glm::mat4 camera_view = glm::mat4(1.0f);
camera_view = glm::lookAt(camera_pos, glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
// camera projection 矩阵
glm::mat4 camera_projection = glm::mat4(1.0f);
camera_projection = glm::perspective(glm::radians(60.0f), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
blinnPhongShader.setMat4("model", camera_model);
blinnPhongShader.setMat4("view", camera_view);
blinnPhongShader.setMat4("projection", camera_projection);
blinnPhongShader.setVec3("k", k[0], k[1], k[2]);
blinnPhongShader.setVec3("cameraPos", camera_pos);
blinnPhongShader.setVec3("lightPos", light_pos);
ourModel.Draw(blinnPhongShader);
// 5.2 使用 transShader 渲染透明的物体
transparentShader.use();
transparentShader.setMat4("model", camera_model);
transparentShader.setMat4("view", camera_view);
transparentShader.setMat4("projection", camera_projection);
transWinModel0.Draw(transparentShader);
transWinModel1.Draw(transparentShader);
glfwSwapBuffers(window); // 在gfw中启用双缓冲,确保绘制的平滑和无缝切换
glfwPollEvents(); // 用于处理所有挂起的事件,例如键盘输入、鼠标移动、窗口大小变化等事件
}
/************************************/
/****** 6.释放资源 ******/
// glfw 释放 glfw使用的所有资源
glfwTerminate();
/************************************/
return 0;
}
// 用于处理用户输入的函数
void processInput(GLFWwindow *window)
{
// 当按下 Esc 按键时调用 glfwSetWindowShouldClose() 函数,关闭窗口
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
{
glfwSetWindowShouldClose(window, true);
}
}
// 在使用 OpenGL 和 GLFW 库时,处理窗口大小改变的回调函数
// 当窗口大小发生变化时,确保 OpenGL 渲染的内容能够适应新的窗口大小,避免图像被拉伸、压缩或出现其他比例失真的问题
void framebuffer_size_callback(GLFWwindow *window, int width, int height)
{
SCR_WIDTH = width;
SCR_HEIGHT = height;
glViewport(0, 0, width, height);
}
7. 编译运行及结果
编译运行:
cd ./build
cmake ..
make
./OpenGL_Blending
渲染结果:
四、透明效果中存在的问题
如果调整相机的位置,会发现当渲染多个透明物体,并且透明物体相互重叠时会出现渲染结果错误,如下图所示:
这是因为深度测试和混合一起使用的话会产生一些麻烦。当写入深度缓冲时,深度缓冲不会检查片段是否是透明的,所以(左下的窗户)透明的部分会和其它值一样写入到深度缓冲中。结果就是前面窗户的整个四边形不论透明度都会进行深度测试。即使透明的部分应该显示后面的窗户(右上的窗户),深度测试仍然丢弃了它们(右上的窗户)
。
一个解决该问题的思路要想保证窗户中能够显示它们背后的窗户,我们需要首先绘制背后的这部分窗户。这也就是说在绘制的时候,我们必须先手动将窗户按照最远到最近来排序,再按照顺序渲染
。
即,当绘制一个有不透明和透明物体的场景的时候,大体的原则如下:
- 先绘制所有不透明的物体。
- 对所有透明的物体排序。
- 按顺序绘制所有透明的物体。
但是这也不能完美的解决所有的问题,毕竟如何确定奇怪形状的物体距离相机的远近也是一个很复杂的问题。
总结来讲:在场景中排序物体是一个很困难的技术,很大程度上由你场景的类型所决定,更别说它额外需要消耗的处理能力了。完整渲染一个包含不透明和透明物体的场景并不是那么容易。更高级的技术还有次序无关透明度(Order Independent Transparency, OIT)。
接下来有机会的话也会介绍与OIT相关的方法介绍以及代码分享。
五、全部代码及模型文件
全部代码以及模型文件可以在使用OpenGL实现透明效果中下载。
六、参考
[1.] LearnOpenGL-CN-高级OpenGL-混合