前文传送门:https://zhuanlan.zhihu.com/p/97371838
鸣谢:https://chaosinmotion.blog/2016/05/22/3d-clipping-in-homogeneous-coordinates/
之前在做软渲染器的时候,我直接把穿过近平面的三角形全部丢弃,对剩下的图形使用逐边裁剪算法,这样的做法在屏幕中间显示物体还行,但是要显示地面或者skybox之类的东西就完全不够用了。最近在研究PBR相关的内容,恰好就要用到skybox,于是索性把整个裁剪剔除系统进行了重构。没想到,这一研究又踩到了大坑:国内的文章基本就是照着书抄或者复读不知道几手的博客,算法也都是在二维下的,国外倒是有不少人遇到这个问题,但是都是讲原理,实现上有些细节坑也是难找。最终结合了好几篇资料,终于是走通了整个裁剪流程,于是决定写篇文章帮助后人避坑。话不多说,我们开始。
裁剪在渲染管线中的位置
准确来说,渲染管线中进行裁剪的位置是透视投影之后,齐次除法之前。我们知道,在世界空间和观察空间中,一个点的坐标是[X,Y,Z,1],经过透视投影之后变为[X',Y',Z',-Z],再除以W坐标变化到NDC中[X'/-Z, Y'/-Z, Z'/-Z, 1]。这其中,如果一个点在观察者的身后,其观察坐标Z会大于0(观察空间是右手系),那么透视投影之后W会小于0,进行透视除法会导致顶点的X,Y坐标上下左右翻转。
而且,如果物体恰好在视点平面上,Z=0,那么W=0,会导致除0错误。
齐次裁剪空间
透视投影之后透视除法之前的坐标空间被称为裁剪空间,也叫齐次(裁剪)空间,它实质上是一个四维空间,变换到齐次空间的顶点之间仍然是线性相关的(可以直接使用线性插值而不是透视插值)。视锥体中的点,都满足如下条件
bool ClipSpaceCull(const glm::vec4 &v1, const glm::vec4 &v2, const glm::vec4 &v3) {
if (v1.w < camera->Near && v2.w < camera->Near && v3.w < camera->Near)
return false;
if (v1.w > camera->Far && v2.w > camera->Far && v3.w > camera->Far)
return false;
if (v1.x > v1.w && v2.x > v2.w && v3.x > v3.w)
return false;
if (v1.x < -v1.w && v2.x < -v2.w && v3.x < -v3.w)
return false;
if (v1.y > v1.w && v2.y > v2.w && v3.y > v3.w)
return false;
if (v1.y < -v1.w && v2.y < -v2.w && v3.y < -v3.w)
return false;
if (v1.z > v1.w && v2.z > v2.w && v3.z > v3.w)
return false;
if (v1.z < -v1.w && v2.z < -v2.w && v3.z < -v3.w)
return false;
return true;
}
另外,正向背向面剔除可以在NDC中进行,因为在绘制skybox的时候,会使用世界坐标固定的盒子,继续在世界空间进行面剔除会导致错误
enum Face {
Back,
Front
};
//面剔除,剔除正向面或者逆向面
bool FaceCull(Face face, const glm::vec4 &v1, const glm::vec4 &v2, const glm::vec4 &v3) {
glm::vec3 tmp1 = glm::vec3(v2.x - v1.x, v2.y - v1.y, v2.z - v1.z);
glm::vec3 tmp2 = glm::vec3(v3.x - v1.x, v3.y - v1.y, v3.z - v1.z);
//叉乘得到法向量
glm::vec3 normal = glm::normalize(glm::cross(tmp1, tmp2));
//glm::vec3 view = glm::normalize(glm::vec3(v1.x - camera->Position.x, v1.y - camera->Position.y, v1.z - camera->Position.z));
//NDC中观察方向指向+z
glm::vec3 view = glm::vec3(0, 0, 1);
if (face == Back)
return glm::dot(normal, view) > 0;
else
return glm::dot(normal, view) < 0;
}
在齐次空间使用Sutherland-Hodgeman裁剪算法
Sutherland-Hodgeman算法或者叫逐边裁剪算法,在二维平面下的原理是遍历每条裁剪边,生成顶点并作为下一条边的输入(具体原理可以看开头的前文)
这个算法在齐次空间也同样适用(而且可以推广到任意维),与二维的区别是,裁剪平面变为了6个(而不是四条线),使用点到平面的距离来判断在平面的内外和进行插值。原理推导如下:
在NDC中,一点 P [x/w, y/w, z/w] 与平面 Ax+By+Cz+D=0(法向量n=[A,B,C]) 的距离d = Ax/w + By/w + Cz/w + D,若 d>0则在平面法向量所指区域内,d=0在平面上,d<0在另一侧区域。
分别在一个平面两侧的两个点A,B,它们连线与平面的交点C可以通过权重da/(da-db)从A到B插值得到。
在齐次空间中,所有等式两次同乘w,dw = Ax+By+Cz+Dw,依然是 dw>0就在我们要的区域内。插值的权重也变为daaw / (daaw - db*bw)。
6个裁剪平面的法向量如下
const std::vector<glm::vec4> ViewLines = {
//Near
glm::vec4(0,0,1,1),
//far
glm::vec4(0,0,-1,1),
//left
glm::vec4(1,0,0,1),
//top
glm::vec4(0,1,0,1),
//right
glm::vec4(-1,0,0,1),
//bottom
glm::vec4(0,-1,0,1)
};
判断内外的函数与插值计算
bool Inside(const glm::vec4 &line,const glm::vec4 &p) {
return line.x * p.x + line.y * p.y + line.z * p.z + line.w * p.w >= 0;
}
//交点,通过端点插值
V2F Intersect(const V2F &v1,const V2F &v2,const glm::vec4 &line) {
float da = v1.windowPos.x * line.x + v1.windowPos.y * line.y + v1.windowPos.z *line.z + line.w * v1.windowPos.w;
float db = v2.windowPos.x * line.x + v2.windowPos.y * line.y + v2.windowPos.z *line.z + line.w * v2.windowPos.w;
float weight = da / (da-db);
return V2F::lerp(v1, v2, weight);
}
而真正运行的函数体实际上是不变的
//输入 三个顶点 输出 裁剪后的顶点组
std::vector<V2F> SutherlandHodgeman(const V2F &v1, const V2F &v2, const V2F &v3) {
std::vector<V2F> output = {v1,v2,v3};
if (AllVertexsInside(output)) {
return output;
}
for (int i = 0; i < ViewLines.size() ; i++) {
std::vector<V2F> input(output);
output.clear();
for (int j = 0; j < input.size(); j++) {
V2F current = input[j];
V2F last = input[(j + input.size() - 1) % input.size()];
if (Inside(ViewLines[i], current.windowPos)) {
if (!Inside(ViewLines[i],last.windowPos)) {
V2F intersecting = Intersect(last, current, ViewLines[i]);
output.push_back(intersecting);
}
output.push_back(current);
}
else if(Inside(ViewLines[i], last.windowPos)){
V2F intersecting = Intersect(last, current, ViewLines[i]);
output.push_back(intersecting);
}
}
}
return output;
}
渲染流程的修改
现在我们在齐次空间进行裁剪,需要线性插值,因此不能提前除以z值了。把顶点着色器中的部分代码移动到透视除法中
virtual V2F VertexShader(const Vertex &a2v) {
V2F o;
o.worldPos = ModelMatrix * a2v.position;
// PVM*v
o.windowPos = ProjectMatrix * ViewMatrix * o.worldPos;
o.normal = glm::normalize(NormalMatrix * a2v.normal);
o.texcoord = a2v.texcoord;
o.color = a2v.color;
return o;
}
//透视除法
void PerspectiveDivision(V2F & v) {
v.Z = 1 / v.windowPos.w;
v.windowPos /= v.windowPos.w;
v.windowPos.w = 1.0f;
v.windowPos.z = (v.windowPos.z + 1.0) * 0.5;
v.worldPos *= v.Z;
v.texcoord *= v.Z;
v.color *= v.Z;
}
......
if (viewCull && !ClipSpaceCull(v1.windowPos , v2.windowPos, v3.windowPos)) {
continue;
}
//裁剪
std::vector<V2F> clipingVertexs = SutherlandHodgeman(v1,v2,v3);
//透视除法
for (int i = 0; i < clipingVertexs.size(); i++) {
PerspectiveDivision(clipingVertexs[i]);
}
//画出最终的三角形 顶点组装方式是GL_TRIANGLES_FAN
int n = clipingVertexs.size() - 3 + 1;
for (int i = 0; i < n; i++) {
.....
现在可以近距离观察物体了