第四章:图元装配器(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