前文传送门: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++) {
.....

现在可以近距离观察物体了