1、为什么要裁剪
OpenGL 规定了我们可见的顶点都应该在标准视体之内,也就是在标准化设备坐标(NDC)中,每个顶点的 x、y、z 坐标都应该在 -1.0 到 1.0 之间,对应的 xyz 范围就是 -1 <= x,y,z <= 1,超出这个范围的顶点都将不可见。
假设在局部空间中也就是在应用程序中传递的坐标 p(x, y, z, 1),经过模型变换为 (x_model, y_model, z_model, 1),再经过视图变换为 (x_view, y_view, z_view, 1),最后经过透视投影坐标就变成了 (x_clip, y_clip, z_clip, -z_view),裁剪空间坐标除以 w = -z_view 变换到 NDC 空间中 (x_clip/w, y_clip/w, z_clip/w, 1),那么所有的顶点都应该满足 -1 <= x_clip/w, y_clip/w, z_clip/w <= 1,也就是 -w <= x_clip, y_clip, z_clip <= w,不满足这个条件的点,理论上来说都应该被裁剪掉。
2、裁剪平面
根据 -w <= x_clip, y_clip, z_clip <= w 这个条件,我们可以得到六个边界条件
左:-w <= x,边界为 -w = x,也就是 x + w = 0
右: x <= w,边界为 x = w,也就是 -x + w = 0
下:-w <= y,边界为 -w = y,也就是 y + w = 0
上: y <= w,边界为 y = w,也就是 -y + w = 0
近:-w <= z, 边界为 -w = z,也就是 w + z = 0
远: z <= w,边界为 z = w,也就是 -z + w = 0
我们可以将 w 当作一个常数,那么这六个边界条件实际上就对应着六个裁剪平面,满足六个条件的区域是一个立方体,完全在立方体之内的三角形可以正常进行渲染,完全在立方体之外的三角形可以直接丢弃,而部分在外的三角形,就需要通过裁剪将顶点裁到边界平面之内。
三维平面的一般形式方程为 Ax + By + Cz + D,其中 n = (A, B, C) 为平面法线,对于任意一点 P(x, y, z),其到平面的距离 d = Ax + By + Cz + D,如果 d = 0 则点在平面上,d > 0 认为在平面内,d < 0 认为在平面外。
那么根据六个边界条件,我们可以得到六个平面方程
左: x + w = 0, A = 1, B = 0, C = 0, D = w
右:-x + w = 0, A = -1, B = 0, C = 0, D = w
下: y + w = 0, A = 0, B = 1, C = 0, D = w
上:-y + w = 0, A = 0, B = -1, C = 0, D = w
近: w + z = 0, A = 0, B = 0, C = 1, D = w
远:-z + w = 0, A = 0, B = 0, C = -1, D = w
这里有一个容易困惑的点,我们默认的是 -w < w 的,但是如果 w <= 0,那么就变成了 -w >= w 了,所以在进行实际裁剪的时候,要先处理掉所有 w <= 0 的情况,也就是在观察者身后的点,因为在观察者身后时 z_view 会大于 0,那么透视之后的 w 就会小于 0。这里再看一下之前提到过的透视投影矩阵。
当 z_view > 0,那么 w = -z_view,那么 w 就一定是小于 0 的,那么对应的 z_clip 就等于
z_clip = z_view * (near+far)/(near-far) + (2*near*far)/(near-far)
= (z_view * (near+far) + (2*near*far)) / (near-far)
因为 near-far 是小于 0 的,当 z_view 大于 0 时,z_clip 肯定也是小于 0 的,那么 z_clip + w_clip 一定是小于 0 的,那么近平面的判断一定不成立,所以先进行近平面的裁剪就可以裁剪掉 w <= 0 的情况,还有我们可以看透视投影的平截头体
摄像机的位置就是观察者的位置,近平面肯定是在观察者前面的,如果一个点坐标在观察者身后,那么肯定在近平面之外的,所以先进行近平面的裁剪就会将所有观察者身后的点裁掉。
3、线段与平面的交点
有两点 p1(x1, y1, z1) 和 p2(x2, y2, z2) 分别在平面的两侧,p1 在平面内侧,p2 在平面外侧,d1 代表 p1 到平面的距离,那么 d1 > 0,d2 代表 p2 到平面的距离,那么 d2 < 0,那么可以通过权重 d = d1 / (d1 - d2),计算出来线段与平面的交点 p3。
P3 = p2 * d + (1 - d) * p1
如下图所示
这里为啥是 p2 乘以 d,而不是 p1 乘以 d,这是因为 d1 越大,就离平面越远,占比就要越小,但是 d1 / (d1 - d2) 就越大,所以这里 p1 需要乘以的是 1 - d。
4、Sutherland-Hodgeman 裁剪算法
又称为逐边裁剪算法,它的算法原理是每次使用裁剪框的一条边去裁剪多边形的每一条边,算法流程如下
1. 将多边形所有顶点作为一个输入数据进行原理输入
std::vector<Point> input{p1, p2, p3};
2. 将每两个顶点组成一条线段来进行每一个平面的裁剪
for (int i = 0; i < plane_num; ++i) {
for (int i = 0; i < input.size(); ++i) {
// input[i] input[(i+1)&input.size()]
}
}
3. 线段和裁剪面的关系可能有以下几种
都在可见面内,那么将第一个顶点 A 放入输出 output 数组
都在可见点外部,不做处理
第一个点在内部,第二个点在外部,将 A 点放入 output,以及 AB 与可见面的交点 P 也放入 output
第一个点在外部,第二个点在内部,将交点 P 放入 output
4. 将 output 数组,作为下一个裁剪面的输入,继续进行第 2 步,直到所有裁剪面都遍历结束
举个例子
假设三角形的三个顶点分别为 A、B、C 的位置如下
接下来我们分别对四个平面(left、right、top、bottom)进行裁剪
1. 判断 AB,BC,CA 三条线段和 left 平面的位置关系
判断 AB:A 在内,B 在外,那么将 A 点放入 output 数组,并将交点 D 放入 output 数组
判断 BC:B 在外,C 在内,将 BC 交点 E 放入 output
判断 CA:C 在内,A 在内,将 C 点放入 output 数组
第一次裁剪完 left 平面,output 数组内容如下
output = {A, D, E, C}
就变成了下面这样
2. 接下来进行 right 平面的裁剪
判断 AD:A 在内,D 在内,将 A 放入 output
判断 DE:D 在内,E 在内,将 D 放入 output
判断 EC:E 在内,C 在外,将 E 放入 output,并将交点 F 放入 output
判断 CA:C 在外,A 在内,将交点 H 放入 output
裁剪完后的 output 变为
output = {A, D, E, F, H}
3. 接下来进行 top 的裁剪
判断 AD:A 在外,D 在内,将交点 I 放入 putput
判断 DE:D 在内,E 在内,将交点 D 放入 putput
判断 EF:E 在内,F 在内,将交点 E 放入 putput
判断 FH:F 在内,H 在内,将交点 F 放入 putput
判断 HA:H 在内,A 在外,将 H 放入 output,并将交点 J 放入 output
裁剪完后的 output 变为
output = {I, D, E, F, H, J}
4. 进行 bottom 的裁剪,都在 bottom 内部,裁剪完顶点没有变化
5. 组装三角形,这个时候的顶点为
output = {I, D, E, F, H, J}
这个时候按照 GL_TRIANGLE_FAN 的方式组装三角形,那么三角形分别为
{I, D, E}
{I, E, F}
{I, F, H}
{I, H, J}
也就是下面这样
5、代码实现
六个平面系数如下
std::vector<std::vector<int>> planes{
{ 0, 0, 1, 1}, // near
{ 0, 0, -1, 1}, // far
{ 1, 0, 0, 1}, // left
{-1, 0, 0, 1}, // right
{ 0, 1, 0, 1}, // bottom
{ 0, -1, 0, 1}, // top
};
判断点是不是在平面内
bool Inside(const Point& point, const std::vector<int>& plane) {
return point.x * plane[0] + point.y * plane[1] + point.z * plane[2] + point.w * plane[3] >= 0;
}
计算交点
Point Intersect(const Point& p1, const Point& p2, const std::vector<int>& plane) {
float d1 = p1.x * plane[0] + p1.y * plane[1] + p1.z * plane[2] + p1.w * plane[3];
float d2 = p2.x * plane[0] + p2.y * plane[1] + p2.z * plane[2] + p2.w * plane[3];
float d = d1 / (d1 - d2);
Point p3 = p1 * (1 - d) + p2 * d;
return p3;
}
完整代码
bool Outside(const Point& point, const std::vector<int>& plane) {
return !Inside(point, plane);
}
void ClipTriangle(const std::vector<Point>& points) {
std::vector<Point> output = points;
for (const auto& type : planes) {
std::vector<Point> input(output);
output.clear();
for (size_t i = 0; i < input.size(); ++i) {
if (Outside(input[i], type) && Inside(input[(i + 1) % input.size()], type)) {
Point p = Intersect(input[i], input[(i + 1) % input.size()], type);
output.push_back(p);
} else if (Inside(input[i], type) && Outside(input[(i + 1) % input.size()], type)) {
Point p = input[i];
output.push_back(p);
p = Intersect(input[i], input[(i + 1) % input.size()], type);
output.push_back(p);
} else if (Inside(input[i], type) && Inside(input[(i + 1) % input.size()], type)) {
Point p = input[i];
output.push_back(p);
}
}
}
for (int i = 0; i < output.size() - 2; ++i) {
for (size_t j = i; j < i + 3; ++j) {
int index = j;
if (j == i) {
index = 0;
}
std::cout << output[index].x << " " << output[index].y << std::endl;
}
std::cout << "=====" << std::endl;
}
}
6、其它图元裁剪
在上面我们说了三角形的裁剪,对于线段来说,和三角形的裁剪类似,区别就是线段没有最后的图元组装,对于点来说,如果点在远近平面之外,那么直接丢弃,其它情况放在光栅化阶段做裁剪,因为点有 pointsize。其实对于三角形和线段来说,这里也可以只做远近面的裁剪,上下左右的裁剪都可以在光栅化阶段进行。