《LearnOpenGL》这篇文章主要侧重于如何使用 OpenGL 的 API 来开启和配置面剔除,确实没有深入探讨底层数学和实现逻辑。
面剔除的核心原理其实并不复杂,它主要依赖于线性代数中的向量叉乘和点乘。
下面我将为你详细拆解面剔除的具体实现原理,并展示如何用代码(C++/数学库)手动实现这一过程。
核心原理:法向量与视线方向
面剔除的本质是判断一个三角形面是**"朝向摄像机"还是"背向摄像机"**。
1. 顶点环绕顺序
在 3D 图形学中,三角形的三个顶点定义顺序至关重要。
- 逆时针:通常定义为"正面"。
- 顺时针:通常定义为"背面"。
(注:OpenGL 默认是逆时针为正面,可以通过 glFrontFace 修改)
2. 计算三角形法向量
当我们有三角形的三个顶点 V1,V2,V3V_1, V_2, V_3V1,V2,V3 时,我们可以通过叉乘计算出该三角形的法向量 N⃗\vec{N}N 。
叉乘的顺序决定了法向量的方向:
- U⃗=V2−V1\vec{U} = V_2 - V_1U =V2−V1
- V⃗=V3−V1\vec{V} = V_3 - V_1V =V3−V1
- N⃗=U⃗×V⃗\vec{N} = \vec{U} \times \vec{V}N =U ×V
如果顶点顺序是逆时针的,计算出的法向量会指向三角形的外部(朝向观察者);如果是顺时针的,法向量会指向内部。
3. 判断可见性(点乘)
有了法向量 N⃗\vec{N}N 和视线向量 L⃗\vec{L}L (从三角形指向摄像机),我们使用点乘来判断方向:
result=N⃗⋅L⃗ result = \vec{N} \cdot \vec{L} result=N ⋅L
- 如果 result>0result > 0result>0:法向量与视线方向一致,说明面朝向摄像机(正面)。
- 如果 result<0result < 0result<0:法向量与视线方向相反,说明面背向摄像机(背面)。
- 如果 result=0result = 0result=0:面与视线垂直(侧视)。
实际实现方式:屏幕空间判定
虽然上面的原理在世界空间是正确的,但在 GPU 内部(光栅化阶段),面剔除通常发生在裁剪空间 或屏幕空间。这种方法效率更高,因为不需要计算真实的 3D 法向量,也不需要视线向量,只需要看投影后的三角形面积符号。
具体算法(有符号面积法):
当顶点被变换到 2D 屏幕坐标 (x,y)(x, y)(x,y) 后,我们计算三角形覆盖的面积。如果面积为正,代表逆时针;为负,代表顺时针。
公式如下(基于叉积的 2D 简化版):
S=(x2−x1)(y3−y1)−(y2−y1)(x3−x1) S = (x_2 - x_1)(y_3 - y_1) - (y_2 - y_1)(x_3 - x_1) S=(x2−x1)(y3−y1)−(y2−y1)(x3−x1)
- S>0S > 0S>0:顶点顺序为逆时针。
- S<0S < 0S<0:顶点顺序为顺时针。
- S=0S = 0S=0:三角形退化成一条线。
GPU 会根据这个 SSS 的符号以及用户设置的剔除模式(GL_BACK 或 GL_FRONT)决定是否丢弃该三角形。
代码实现演示
为了让你彻底理解,我将用 C++ 代码模拟这个过程。我们假设有一个简单的软件光栅化器流程。
第一步:定义数学工具
我们需要一个简单的向量结构体和点乘/叉乘运算。
cpp
#include <iostream>
#include <vector>
#include <cmath>
struct Vec3 {
float x, y, z;
};
// 向量相减
Vec3 sub(const Vec3& a, const Vec3& b) {
return {a.x - b.x, a.y - b.y, a.z - b.z};
}
// 3D 叉乘 (用于计算法向量)
Vec3 cross(const Vec3& a, const Vec3& b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
// 点乘
float dot(const Vec3& a, const Vec3& b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
// 2D 叉乘 (用于屏幕空间判定,实际上是计算有符号面积)
// 返回值相当于 z 轴分量
float cross_2d(float x1, float y1, float x2, float y2) {
return x1 * y2 - y1 * x2;
}
第二步:实现面剔除逻辑
这里展示两种方法:
- 3D 空间法(直观,容易理解几何意义)。
- 屏幕空间法(高效,GPU 实际采用的方式)。
cpp
// 定义顶点顺序模式
enum WindingOrder {
CCW, // 逆时针
CW // 顺时针
};
// 定义剔除模式
enum CullMode {
NONE,
FRONT,
BACK
};
// ==========================================
// 方法一:3D 空间法 (世界坐标系)
// ==========================================
bool ShouldCull_3D(const Vec3& v1, const Vec3& v2, const Vec3& v3,
const Vec3& cameraPos, CullMode mode) {
// 1. 计算三角形的两条边
Vec3 edge1 = sub(v2, v1);
Vec3 edge2 = sub(v3, v1);
// 2. 叉乘得到法向量
// 注意:如果顶点是逆时针排列,法向量指向前方
Vec3 normal = cross(edge1, edge2);
// 3. 计算视线向量 (从三角形中心指向摄像机)
Vec3 center = {(v1.x + v2.x + v3.x) / 3.0f,
(v1.y + v2.y + v3.y) / 3.0f,
(v1.z + v2.z + v3.z) / 3.0f};
Vec3 viewDir = sub(cameraPos, center);
// 4. 点乘判断方向
float dotResult = dot(normal, viewDir);
// 如果点乘 < 0,说明法向量与视线方向相反,即面背向摄像机
bool isBackFacing = (dotResult < 0);
// 根据设定的剔除模式决定是否剔除
if (mode == BACK && isBackFacing) return true;
if (mode == FRONT && !isBackFacing) return true;
return false;
}
// ==========================================
// 方法二:屏幕空间法 (投影后 - GPU 标准做法)
// ==========================================
// 假设顶点已经经过 MVP 变换并除以了 w,变成了 2D 坐标
bool ShouldCull_2D(float x1, float y1, float x2, float y2, float x3, float y3,
CullMode mode, WindingOrder expectedFrontFace) {
// 计算有符号面积 (叉积的 z 分量)
// 公式: (v2 - v1) x (v3 - v1)
float signedArea = cross_2d(x2 - x1, y2 - y1, x3 - x1, y3 - y1);
bool isCCW = (signedArea > 0);
// OpenGL 默认 CCW 为正面
bool isFrontFace;
if (expectedFrontFace == CCW) {
isFrontFace = isCCW;
} else {
isFrontFace = !isCCW;
}
// 判定是否剔除
if (mode == BACK && !isFrontFace) return true;
if (mode == FRONT && isFrontFace) return true;
return false;
}
第三步:测试
让我们用一个简单的例子来验证。
cpp
int main() {
// 定义一个三角形
Vec3 v1 = {0.0f, 0.0f, 0.0f};
Vec3 v2 = {1.0f, 0.0f, 0.0f};
Vec3 v3 = {0.0f, 1.0f, 0.0f};
// 摄像机位置
Vec3 cameraPos = {0.0f, 0.0f, 5.0f}; // 在 Z 轴正方向看
std::cout << "--- 测试 3D 空间法 ---" << std::endl;
// 情况 A: v1 -> v2 -> v3 (逆时针 CCW)
// 观察者看向 Z 轴负方向时,这个顺序是逆时针
bool cull1 = ShouldCull_3D(v1, v2, v3, cameraPos, BACK);
std::cout << "顶点顺序 v1-v2-v3 (CCW), 剔除背面: "
<< (cull1 ? "已剔除" : "保留") << std::endl;
// 情况 B: v1 -> v3 -> v2 (顺时针 CW)
bool cull2 = ShouldCull_3D(v1, v3, v2, cameraPos, BACK);
std::cout << "顶点顺序 v1-v3-v2 (CW), 剔除背面: "
<< (cull2 ? "已剔除" : "保留") << std::endl;
std::cout << "\n--- 测试屏幕空间法 (模拟 GPU) ---" << std::endl;
// 模拟投影后的 2D 坐标
// 注意:屏幕坐标系 Y 轴通常向下,但在数学计算中我们通常假设 Y 轴向上。
// 这里假设标准笛卡尔坐标系
// 逆时针三角形
float s1 = cross_2d(1, 0, 0, 1);
std::cout << "2D 叉积结果 (CCW): " << s1 << " (正数)" << std::endl;
// 顺时针三角形
float s2 = cross_2d(0, 1, 1, 0);
std::cout << "2D 叉积结果 (CW): " << s2 << " (负数)" << std::endl;
return 0;
}
总结
OpenGL 内部实现面剔除的流程如下:
- 顶点处理:顶点着色器运行完毕,顶点变换到裁剪空间。
- 透视除法 :执行除以 www 操作,得到归一化设备坐标 (NDC)。
- 视口变换 :将 NDC 映射到屏幕窗口坐标 (x,y)(x, y)(x,y)。
- 面剔除计算 :
- 计算三角形面积:S=(x2−x1)(y3−y1)−(y2−y1)(x3−x1)S = (x_2-x_1)(y_3-y_1) - (y_2-y_1)(x_3-x_1)S=(x2−x1)(y3−y1)−(y2−y1)(x3−x1)。
- 根据
glFrontFace(CCW/CW)设定,判断符号位是否代表正面。 - 根据
glCullFace(BACK/FRONT)设定,如果符合剔除条件,则直接丢弃该三角形,不进入片段着色器。
这就是为什么面剔除是一个极其高效的操作------它在片段着色器运行之前就丢弃了整个三角形,且计算量仅仅是一次简单的 2D 叉乘。