本文整理 LearnOpenGL Advanced OpenGL 中的 Blending(混合) 和 Face Culling(面剔除) 两节内容,并补充一些便于理解和记忆的说明。(LearnOpenGL CN)
OpenGL 中,混合(Blending)主要用于实现透明、半透明效果。
一个半透明物体最终显示的颜色,并不完全来自物体本身,而是:
例如一块半透明玻璃:
摄像机
↓
┌─────────┐
│ 半透明玻璃 │ ← Source
└─────────┘
↓
┌─────────┐
│ 后面的墙体 │ ← Destination
└─────────┘
通常通过颜色的第四个分量 Alpha 表示透明程度:
vec4 color = vec4(r, g, b, alpha);
例如:
alpha = 1.0 完全不透明
alpha = 0.5 半透明
alpha = 0.0 完全透明
需要注意:
Alpha 本身更准确地说表示当前颜色的“不透明程度”,而不是“透明度”。
例如 ,可以理解为当前物体颜色贡献约 80%。
透明纹理通常有两类情况:
透明纹理
│
├── 只有完全透明 / 完全不透明
│ ↓
│ discard
│
└── 存在半透明
↓
Blending
例如:
discard;如果纹理包含 Alpha 通道,那么纹理数据需要使用 GL_RGBA:
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA,
width,
height,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
data
);
片段着色器也需要保留 Alpha:
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D texture1;
void main()
{
FragColor = texture(texture1, TexCoords);
}
这里 texture() 返回:
vec4(R, G, B, A)
而不是只使用 RGB。
如果纹理只有:
alpha = 0
和:
alpha = 1
两种状态,例如草、树叶等,可以直接在片段着色器中丢弃透明片段:
void main()
{
vec4 texColor = texture(texture1, TexCoords);
if (texColor.a < 0.1)
discard;
FragColor = texColor;
}
discard 的作用是:
立即终止当前 Fragment 的处理,这个 Fragment 不会写入颜色缓冲。
可以把它理解成:
Fragment Shader
│
├── alpha >= 0.1
│ ↓
│ 正常输出
│
└── alpha < 0.1
↓
discard
↓
不产生像素
它不是把颜色设置为透明:
FragColor = vec4(0, 0, 0, 0);
而是这个 Fragment 直接不存在了。(LearnOpenGL CN)
透明纹理使用:
GL_REPEAT
时,边缘可能出现一圈奇怪的半透明颜色。
原因是纹理过滤会对边缘 Texel 进行插值,而 GL_REPEAT 会让纹理一侧和另一侧的数据参与插值。
因此对于草、树叶等 Alpha 纹理,通常设置:
glTexParameteri(
GL_TEXTURE_2D,
GL_TEXTURE_WRAP_S,
GL_CLAMP_TO_EDGE
);
glTexParameteri(
GL_TEXTURE_2D,
GL_TEXTURE_WRAP_T,
GL_CLAMP_TO_EDGE
);
这样纹理边界不会和另一侧重复区域进行插值。(LearnOpenGL CN)
对于真正的半透明,例如:
alpha = 0.2
alpha = 0.5
alpha = 0.8
不能简单使用 discard。
需要开启 OpenGL 的混合:
glEnable(GL_BLEND);
然后设置混合方式:
glBlendFunc(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA
);
这是最常见的 Alpha Blending 配置。(LearnOpenGL CN)
可以把简化后的 Fragment 处理过程理解成:
Fragment Shader
│
↓
输出 Fragment Color
│
↓
Stencil Test
│
↓
Depth Test
│
↓
Blending
│
↓
Color Buffer
Fragment Shader 产生的是一个新的颜色。
而当前 Framebuffer 对应像素位置通常已经有一个颜色。
所以出现两个颜色:
Fragment Shader 输出
↓
Source Color
│
├─────┐
│ │
│ Blending
│ │
└─────┤
↓
Result Color
↑
│
Destination Color
↑
Color Buffer
理解 OpenGL Blending 最重要的两个概念:
Source 是:
当前正在绘制的 Fragment 的颜色。
通常就是 Fragment Shader:
FragColor
的输出。
表示为:
Destination 是:
当前 Color Buffer 对应像素位置已经存在的颜色。
表示为:
所以:
Source
↓
当前正在画的 Fragment
Destination
↓
之前已经画进 Framebuffer 的颜色
OpenGL 的基本混合公式可以写成:
其中:
最终:
Result
=
Source × SourceFactor
+
Destination × DestinationFactor
结果重新写入 Color Buffer。(LearnOpenGL CN)
最常见的配置:
glBlendFunc(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA
);
因此:
混合公式变成:
例如当前 Fragment 是绿色:
Source = Green
alpha = 0.6
背景是红色:
Destination = Red
最终颜色:
也就是:
60% 绿色
+
40% 红色
函数定义:
glBlendFunc(
GLenum sfactor,
GLenum dfactor
);
其中:
sfactor
↓
Source Factor
dfactor
↓
Destination Factor
常见参数:
| 参数 | 含义 |
|---|---|
GL_ZERO | |
GL_ONE | |
GL_SRC_COLOR | Source RGB |
GL_ONE_MINUS_SRC_COLOR | |
GL_DST_COLOR | Destination RGB |
GL_ONE_MINUS_DST_COLOR | |
GL_SRC_ALPHA | Source Alpha |
GL_ONE_MINUS_SRC_ALPHA | |
GL_DST_ALPHA | Destination Alpha |
GL_ONE_MINUS_DST_ALPHA |
日常最重要的就是:
glBlendFunc(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA
);
通常 RGB 和 Alpha 使用相同的混合策略。
如果希望分别设置,可以使用:
glBlendFuncSeparate(
srcRGB,
dstRGB,
srcAlpha,
dstAlpha
);
例如:
glBlendFuncSeparate(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA,
GL_ONE,
GL_ZERO
);
前两个参数控制:
RGB
后两个参数控制:
Alpha
前面的公式默认执行的是:
实际上 OpenGL 可以修改两部分之间的运算方式:
glBlendEquation(GL_FUNC_ADD);
常见模式:
| 模式 | 计算 |
|---|---|
GL_FUNC_ADD | |
GL_FUNC_SUBTRACT | |
GL_FUNC_REVERSE_SUBTRACT | |
GL_MIN | |
GL_MAX |
默认:
GL_FUNC_ADD
所以一般情况下不需要手动调用:
glBlendEquation(...)
混合和深度测试结合时,会产生一个非常重要的问题。
假设有两个半透明窗口:
Camera
│
↓
Window A 近
│
↓
Window B 远
如果先画 A:
Depth Buffer
↓
记录 A 的深度
之后再画 B:
B 比 A 更远
↓
Depth Test Failed
↓
B 被丢弃
但是:
A 是半透明的
理论上应该能够通过 A 看到 B。
问题在于:
Depth Buffer 只记录深度,并不知道一个 Fragment 是不是半透明。
即使:
alpha = 0.1
只要这个 Fragment 通过了深度测试,它仍然可能写入深度缓冲。
因此不能完全依赖 Depth Test 处理透明物体。(LearnOpenGL CN)
通常采用:
1. 绘制所有不透明物体
2. 将透明物体按照摄像机距离排序
3. 从远到近绘制透明物体
即:
Opaque
↓
Transparent Far
↓
Transparent
↓
Transparent Near
不透明物体仍然可以正常依靠 Depth Test。
透明物体则需要:
Back-to-Front,从远到近绘制。 (LearnOpenGL CN)
可以计算摄像机和透明物体之间的距离:
float distance =
glm::length(camera.Position - windows[i]);
然后排序。
例如:
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];
}
绘制时反向遍历:
for (
auto it = sorted.rbegin();
it != sorted.rend();
++it
)
{
model = glm::mat4(1.0f);
model = glm::translate(model, it->second);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
即:
distance 大
↓
先画
distance 小
↓
后画
不过这种按照“物体位置”排序只是一个简单方案,对于很大的物体、互相穿插的透明几何体等情况并不能完全解决问题。更复杂的场景可以使用 OIT(Order Independent Transparency,顺序无关透明度)。(LearnOpenGL CN)
两者都可以用于透明纹理,但原理完全不同。
| discard | Blending | |
|---|---|---|
| 适用情况 | 完全透明 / 不透明 | 半透明 |
| Alpha | 通常 0 或 1 | 可以是 0~1 |
| Fragment 是否存在 | 直接丢弃 | 仍然存在 |
| 是否参与混合 | 否 | 是 |
| 是否需要透明排序 | 通常不需要 | 通常需要 |
| 常见场景 | 草、树叶、栅栏 | 玻璃、烟雾、透明 UI |
例如草:
if (texColor.a < 0.1)
discard;
玻璃:
glEnable(GL_BLEND);
glBlendFunc(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA
);
最需要记住的是:
Blending
│
├── Source
│ 当前 Fragment Shader 输出颜色
│
├── Destination
│ Color Buffer 已有颜色
│
└── Result
Source × SrcFactor
+
Destination × DstFactor
普通 Alpha Blending:
对应:
glEnable(GL_BLEND);
glBlendFunc(
GL_SRC_ALPHA,
GL_ONE_MINUS_SRC_ALPHA
);
透明物体绘制原则:
先不透明
↓
透明物体排序
↓
透明物体从远到近
一个封闭的 3D 物体通常有大量从当前观察方向完全看不到的面。
以立方体为例:
一个立方体
↓
6 个面
从外部观察
↓
最多只能看到 3 个面
背面的面最终不会出现在屏幕上。
如果仍然对它们进行完整的光栅化和 Fragment Shader 计算,就会浪费 GPU 资源。
因此可以:
在光栅化阶段直接丢弃背向摄像机的三角形。
这就是:
Face Culling
面剔除。(LearnOpenGL CN)
一个三角形存在两个观察方向:
Front
↑
│
┌──────────┐
│ Triangle │
└──────────┘
│
↓
Back
对于封闭模型:
模型外侧
↓
Front Face
模型内部
↓
Back Face
通常我们只需要绘制:
Front Face
而:
Back Face
可以直接剔除。
OpenGL 并不是简单根据法向量判断正反面。
它主要根据三角形顶点在屏幕上的:
Winding Order(环绕顺序)
判断 Front Face 和 Back Face。
三角形:
v0
│\
│ \
│ \
│ \
v1───v2
顶点可以按照两种方向排列:
Counter-Clockwise
逆时针
v0 → v1 → v2
或者:
Clockwise
顺时针
v0 → v2 → v1
OpenGL 默认:
Counter-Clockwise
↓
Front Face
也就是:
glFrontFace(GL_CCW);
其中:
CCW = Counter Clockwise
逆时针
CW = Clockwise
顺时针
因此模型顶点一般约定:
从模型外部观察时,三角形顶点按照逆时针排列。 (LearnOpenGL CN)
这里是理解 Face Culling 最重要的地方。
假设定义一个三角形时,从它的正面观察:
1 → 2 → 3
是逆时针:
1
/ \
/ \
3 ←── 2
那么:
从正面观察
↓
1 → 2 → 3
↓
CCW
↓
Front Face
但是如果摄像机跑到它的背面:
从背面观察
↓
1 → 2 → 3
↓
CW
↓
Back Face
也就是说:
顶点 Buffer 中存储的顺序没有发生变化,发生变化的是从摄像机视角看到的屏幕空间环绕方向。
因此 OpenGL 能够根据最终投影后的三角形环绕顺序判断它到底面向还是背向摄像机。(LearnOpenGL CN)
一个很重要的点是:
Front Face / Back Face 的判断是在顶点着色器处理之后、光栅化阶段根据投影后的顶点方向进行判断。
可以理解成:
Vertex Data
↓
Vertex Shader
↓
Clip Space
↓
Perspective Divide
↓
Screen / Window Orientation
↓
判断 CW / CCW
↓
Face Culling
↓
Rasterization
因此并不是单纯检查模型空间中:
vertices[0]
vertices[1]
vertices[2]
的坐标顺序。
而是要看:
从当前 Camera 的观察方向来看,这三个顶点最终呈现出来是 CW 还是 CCW。(LearnOpenGL CN)
OpenGL 默认关闭面剔除。
开启:
glEnable(GL_CULL_FACE);
默认情况下会:
剔除 Back Face
等价于:
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glFrontFace(GL_CCW);
这是最常见的配置。(LearnOpenGL CN)
通过:
glCullFace(...);
指定剔除哪一种面。
glCullFace(GL_BACK);
最常用。
表示:
Front → 保留
Back → 丢弃
glCullFace(GL_FRONT);
表示:
Front → 丢弃
Back → 保留
glCullFace(GL_FRONT_AND_BACK);
此时三角形正反面都会被剔除。
可以通过:
glFrontFace(...)
指定哪一种环绕顺序代表 Front Face。
OpenGL 默认:
glFrontFace(GL_CCW);
即:
CCW → Front
CW → Back
也可以改为:
glFrontFace(GL_CW);
此时:
CW → Front
CCW → Back
这两个 API 很容易混淆。
glFrontFace:
定义什么叫 Front Face。
例如:
glFrontFace(GL_CCW);
表示:
CCW = Front
CW = Back
而:
glCullFace(GL_BACK);
表示:
确定了 Front / Back 之后,要丢弃哪一种。
所以完整逻辑是:
三角形
↓
判断 CW / CCW
↓
glFrontFace()
↓
确定 Front / Back
↓
glCullFace()
↓
决定是否丢弃
例如:
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
就是:
CCW
↓
Front
↓
保留
CW
↓
Back
↓
剔除
标准封闭模型一般:
glEnable(GL_CULL_FACE);
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
也就是:
逆时针 = 正面
背面 = 剔除
由于:
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
本身就是默认状态,所以通常只需要:
glEnable(GL_CULL_FACE);
面剔除特别适合:
封闭实体
例如:
因为:
外表面需要显示
内表面通常不需要显示
但是一些模型必须同时看到正反面。
例如草:
Camera
↓
┌───┐
│草 │
└───┘
↑
一个 Quad
如果从 Quad 背面看它,而启用了:
glCullFace(GL_BACK);
整棵草可能直接消失。
因此:
Grass
Leaf
Paper
Cloth
Billboard
某些透明 Quad
往往需要:
glDisable(GL_CULL_FACE);
或者进行双面渲染。(LearnOpenGL CN)
两者都可以减少不可见 Fragment,但原理完全不同。
根据:
Triangle Orientation
决定整个三角形是否需要渲染。
Back Face
↓
整个 Triangle 丢弃
↓
甚至不需要生成 Fragment
三角形已经经过光栅化产生 Fragment:
Triangle
↓
Rasterization
↓
Fragments
↓
Depth Test
↓
判断是否被其它物体遮挡
所以:
Face Culling
发生得更早,可以避免背面三角形产生大量无用 Fragment。
需要区分两个容易混淆的概念:
Face Culling
和:
Frustum Culling
Face Culling 的对象是:
Triangle Face
解决:
这个三角形正面对着 Camera,
还是背面对着 Camera?
Frustum Culling 的对象通常是:
Object / Mesh / Bounding Box
解决:
整个物体是否在摄像机视锥体之外?
例如:
Camera Frustum
/\
/ \
/ A \
/ \
/________\
B
其中:
A
↓
进入渲染流程
↓
其中的 Triangle 再进行 Face Culling
B
↓
整个对象 Frustum Cull
↓
连 Draw Call 都可以省掉
因此两者通常可以同时使用。
可以将整个过程理解为:
Vertex Data
↓
Vertex Shader
↓
Primitive Assembly
↓
Triangle
↓
判断屏幕空间 Winding Order
↓
┌───────────────┐
│ CW 还是 CCW? │
└───────────────┘
↓
glFrontFace()
↓
┌────────────────┐
│ Front / Back? │
└────────────────┘
↓
glCullFace()
↓
是否 Cull
│
┌───┴─────┐
│ │
Cull Keep
│ │
结束 Rasterization
↓
Fragment
↓
Depth Test
↓
Rendering
最常见配置:
glEnable(GL_CULL_FACE);
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
记忆:
glFrontFace
↓
谁是正面?
glCullFace
↓
丢弃谁?
默认:
CCW = Front
CW = Back
Cull Back
最终:
逆时针三角形
↓
Front
↓
保留
顺时针三角形
↓
Back
↓
剔除
这三个功能可以放在一起理解:
Triangle
│
↓
Face Culling
│
│ 整个三角形是否应该存在?
↓
Rasterization
│
↓
Fragments
│
↓
Fragment Shader
│
↓
Stencil / Depth Test
│
│ 当前 Fragment 是否被遮挡?
↓
Blending
│
│ 当前颜色怎样和 Color Buffer 混合?
↓
Color Buffer
它们分别解决不同问题:
| 功能 | 解决的问题 |
|---|---|
| Face Culling | 三角形背面对着摄像机,要不要画? |
| Depth Test | Fragment 是否被前面的物体遮挡? |
| Blending | Fragment 与已有颜色如何组合? |
可以简单记成:
Face Culling
↓
这个面要不要画?
Depth Test
↓
这个像素能不能看到?
Blending
↓
这个像素最终是什么颜色?
这三个问题分别对应 几何可见性、深度可见性和颜色合成。
(完)