第四章:图元装配器(PA)——三角形/线段/点精灵的硬件组装
各位同学,今天我们聊聊图元装配器。嗯,就是那个把顶点数据变成三角形、线段、点精灵的硬件模块。我做了这么多年GPU,PA这块可以说是最容易被忽视,但又最容易出bug的地方。
说白了,PA干的事就三件:组装图元、处理拓扑、剔除裁剪。咱们一个一个说。
4.1 图元类型与拓扑结构
先看最基本的。GPU支持的图元类型其实不多,就三种:点精灵(Point Sprite)、线段(Line)、三角形(Triangle)。但拓扑结构就花样多了。
我个人习惯把拓扑分成两类:
- 列表型(List):每个图元独立,顶点不共享。比如GL_TRIANGLES,每3个顶点一个三角形。
- 条带型(Strip/Fan):顶点复用,节省带宽。比如GL_TRIANGLE_STRIP,每加一个顶点就生成一个新三角形。
你想想看,为什么要有条带?我在项目中遇到过,一个复杂的网格模型如果用列表存,顶点数能翻3倍。而条带结构,复用率高的场景下,带宽能省40%以上。
硬件实现要点:
PA内部维护一个顶点缓存(Vertex Cache),专门处理条带拓扑的顶点复用。这个缓存一般做8-16个entry,用FIFO或者LRU策略。
来看一个典型的三角形条带硬件状态机:
// 伪代码:三角形条带处理
state_e {
WAIT_V0, // 等待第一个顶点
WAIT_V1, // 等待第二个顶点
WAIT_V2, // 等待第三个顶点,此时输出第一个三角形
STRIP // 后续每来一个顶点,输出一个三角形
};
// 顶点缓存管理
vertex_cache[3]; // 环形缓存,存最近3个顶点
cache_ptr = 0;
// 每来一个顶点
if (state == WAIT_V0) {
vertex_cache[0] = new_vertex;
state = WAIT_V1;
} else if (state == WAIT_V1) {
vertex_cache[1] = new_vertex;
state = WAIT_V2;
} else if (state == WAIT_V2) {
vertex_cache[2] = new_vertex;
output_triangle(vertex_cache[0], vertex_cache[1], vertex_cache[2]);
state = STRIP;
cache_ptr = 0;
} else { // STRIP状态
// 复用前两个顶点,新顶点作为第三个
output_triangle(vertex_cache[1], vertex_cache[2], new_vertex);
// 滑动窗口
vertex_cache[0] = vertex_cache[1];
vertex_cache[1] = vertex_cache[2];
vertex_cache[2] = new_vertex;
}
嗯,这里要注意:条带模式下,每输出一个三角形,顶点缓存的滑动窗口要正确更新。我曾经见过一个bug,就是窗口滑动方向搞反了,结果三角形全部扭曲。
4.2 背面剔除的硬件实现
背面剔除,说白了就是判断三角形是正面还是背面。正面保留,背面扔掉。为什么?因为背对摄像机的面,你看不到,渲染它纯属浪费。
判断方法很简单:计算三角形的法线方向。硬件里用叉积算面积符号:
// 背面剔除硬件单元
// 输入:三个顶点的屏幕坐标 (x0,y0), (x1,y1), (x2,y2)
// 输出:剔除标志
// 计算有符号面积(叉积的z分量)
signed_area = (x1 - x0)*(y2 - y0) - (y1 - y0)*(x2 - x0);
// 根据 winding order 判断
if (winding_order == CCW) {
cull_flag = (signed_area <= 0); // 负面积或零面积,剔除
} else { // CW
cull_flag = (signed_area >= 0);
}
这个计算在硬件里就是一个乘加器(MAC),一个时钟周期就能出结果。我建议把signed_area的位宽做到24位以上,不然精度不够,边缘情况容易误判。
避坑指南:
我曾经遇到过一个问题:当三角形退化成线段(面积接近0)时,signed_area的符号可能因为舍入误差而随机。后来我们在硬件里加了一个零面积阈值,绝对值小于阈值的都算退化,直接剔除。
4.3 裁剪的硬件实现
裁剪,就是把超出视口(Viewport)的三角形切掉。注意,不是简单扔掉,而是生成新的顶点,让三角形刚好在视口内。
硬件裁剪一般用Sutherland-Hodgman算法。这个算法对每个裁剪面(左、右、上、下、近、远)依次处理。每次输入一个多边形,输出裁剪后的多边形。
看一个简单的左裁剪面(x >= 0)实现:
// 左裁剪面硬件单元
// 输入:顶点列表 in_vertices[0..n-1]
// 输出:裁剪后的顶点列表 out_vertices[0..m-1]
for (i = 0; i < n; i++) {
current = in_vertices[i];
previous = in_vertices[(i-1+n)%n];
// 判断当前顶点和上一个顶点是否在裁剪面内部
cur_inside = (current.x >= 0);
prev_inside = (previous.x >= 0);
if (cur_inside) {
if (!prev_inside) {
// 从外部进入内部,需要插值生成新顶点
t = -previous.x / (current.x - previous.x);
new_vert = interpolate(previous, current, t);
out_vertices.push(new_vert);
}
out_vertices.push(current);
} else {
if (prev_inside) {
// 从内部到外部,插值生成新顶点
t = -previous.x / (current.x - previous.x);
new_vert = interpolate(previous, current, t);
out_vertices.push(new_vert);
}
// 都在外部,不输出
}
}
这个插值计算,硬件里用线性插值器(LERP)实现。每个顶点属性(位置、颜色、纹理坐标等)都要插值。我建议把插值器做成流水线结构,一个时钟处理一个顶点,吞吐量高。
性能优化技巧:
裁剪最坏情况下,一个三角形可能变成六边形(6个顶点)。所以裁剪单元的顶点FIFO要留够空间。我一般做8个顶点的深度,够用。
另外,如果三角形完全在视口内,可以旁路(bypass)裁剪单元,省一个时钟周期。
4.4 点精灵的特殊处理
点精灵,就是用一个点表示一个精灵(Sprite)。硬件里,点精灵其实是一个始终面向摄像机的正方形。PA需要把点精灵展开成两个三角形。
展开逻辑:
// 点精灵展开
// 输入:点位置 (x, y, z),点大小 size
// 输出:两个三角形(6个顶点)
// 计算半边长
half = size / 2;
// 生成四个角点
v0 = (x - half, y - half, z); // 左下
v1 = (x + half, y - half, z); // 右下
v2 = (x + half, y + half, z); // 右上
v3 = (x - half, y + half, z); // 左上
// 输出两个三角形
triangle1 = (v0, v1, v2);
triangle2 = (v0, v2, v3);
注意,点精灵的纹理坐标是固定的:(0,0)对应左下,(1,1)对应右上。这个在纹理映射阶段会用到。
好了,PA的核心内容就这些。下一章我们讲光栅化,那可是个更刺激的模块。
本章小结:
- PA负责把顶点组装成图元,支持列表和条带两种拓扑
- 背面剔除用有符号面积判断,注意零面积退化问题
- 裁剪用Sutherland-Hodgman算法,逐裁剪面处理,顶点属性要插值
- 点精灵展开成两个三角形,纹理坐标固定
公众号:蓝海资料掘金营,微信deep3321