假如我们需要渲染数以万计的四边形, 包括描边颜色和填充颜色, 并且要求高帧率, 那么单纯使用诸如 GDI+, Skia 这类 CPU 绘图库, 速度无疑是不够的.
在最初, 我想着自己生成一堆顶点, 三角形索引, 让 DirectX 做渲染, 后来我发现, 如果是根据多边形顶点, 构建包含描边色带和填充区域的网格, 就已经非常耗时间了. 况且从内存拷贝到 GPU 的传输损耗也不容小视.
其实后来发现, 我应该重用 VertexBuffer, 并在使用的时候, Map, 然后写入
直到, 我知道了有个东西, 叫做几何着色器. 我完全可以把 “构建网格” 的过程, 交给 GPU 的几何着色器来处理.
几何着色器
几何着色器(Geometry Shader)是 DirectX 10 引入的可编程管线阶段,它运行在顶点着色器之后、光栅化之前。它的强大之处在于能够从输入的图元生成新的图元。
对于我们的四边形渲染,流程是这样的:
- 输入:单个点(包含位置、大小、旋转、颜色等信息)
- 几何着色器处理:根据这个点的信息生成多个三角形
- 输出:形成四边形的三角形流
这样做的好处显而易见:数据传输量大幅减少,GPU 利用率提升。
几何着色器的基本结构
几何着色器的基本语法如下:
[maxvertexcount(15)]
void GS(point GS_INPUT input[1], inout TriangleStream<PS_INPUT> triStream)
{
// 几何着色器实现
}
关键要素:
maxvertexcount(15)
:指定这个几何着色器最多输出多少个顶点point GS_INPUT input[1]
:输入是一个点数组(在我们的例子中只有一个点)inout TriangleStream<PS_INPUT> triStream
:输出三角形流
三角形条带
TriangleStream
表示要输出的三角形流, 调用其 Append
方法可向其中添加一个顶点. 但添加时并不是每三个顶点构成一个三角形, 而是各个顶点之间构成一个三角形条带.
假设有一个正四边形, 其顺时针方向的四个顶点分别是 v0, v1, v2, v3. 正常的网格可能需要这样的数据, 顶点列表 [v0, v1, v2, v3], 三角形索引列表 [0, 1, 2, 0, 2, 3].
但是如果是三角形带, 只需要按照 [v0, v1, v2, v3, v0] 的顺序依次添加进去, 围成一个四边形即可.
每次输出完一个三角形,需要调用 RestartStrip()
来结束当前的三角形条带:
// 输出描边三角形
triStream.Append(v[0]);
// ... 其他顶点
triStream.Append(v[10]);
triStream.RestartStrip();
全局数据
为了定义一些全局数据, 并在着色器中使用, 我们需要定义一个 Constant Buffer。在我们的渲染器中,它包含:
cbuffer ScreenBuffer : register(b0)
{
float2 screenSize; // 屏幕尺寸
float3x3 transform; // 变换矩阵
float3 strokeWidthFactorAndSizeFactor; // 各种缩放因子
};
这里需要注意的是, Constant Buffer 是存在内存对齐的. 所以在 C# 中对应的数据, 是这样的:
ReadOnlySpan<float> constBufferData =
[
_width, _height, 0, 0, // 16字节对齐
_transform.M11, _transform.M12, 0, 0, // 16字节对齐
_transform.M21, _transform.M22, 0, 0, // 16字节对齐
_transform.OffsetX, _transform.OffsetY, 1, 0, // 16字节对齐
_strokeThicknessFactor, _widthFactor, _heightFactor, 0 // 16字节对齐
];
关键点:
- 每行必须是 16 字节(4 个 float)的倍数
float2
会被填充到 16 字节float3
也会被填充到 16 字节- 不对齐会导致 GPU 读取错误的数据
几何着色器中的四边形生成
我们的几何着色器需要从一个点生成带描边的四边形:
[maxvertexcount(30)]
void GS(point GS_INPUT input[1], inout TriangleStream<PS_INPUT> triStream)
{
GS_INPUT i = input[0];
// 计算实际尺寸
float strokeWidth = i.StrokeWidth * strokeWidthFactorAndSizeFactor.x;
float2 size = float2(
i.Size.x * strokeWidthFactorAndSizeFactor.y,
i.Size.y * strokeWidthFactorAndSizeFactor.z
);
// 计算内外矩形
float2 halfSize = size * 0.5f;
float2 outerHalfSize = halfSize + strokeWidth / 2;
float2 innerHalfSize = halfSize - strokeWidth / 2;
// 生成旋转矩阵
float sinR = sin(i.Rotation);
float cosR = cos(i.Rotation);
float2x2 rotMatrix = float2x2(cosR, sinR, -sinR, cosR);
// 计算 8 个关键顶点(外矩形4个 + 内矩形4个)
// ... 顶点计算代码
// 输出描边三角形
// 输出填充三角形
}
坐标系统的转换
从屏幕坐标到裁剪空间的转换是关键环节:
float2 ScreenToClipPoint(float2 input)
{
// 应用变换矩阵
float3 pos = float3(input, 1.0f);
float3 transformedPos = mul(transform, pos);
// 转换到裁剪空间 (-1 到 1)
float2 normalizedPos;
normalizedPos.x = (transformedPos.x / screenSize.x) * 2.0f - 1.0f;
normalizedPos.y = 1.0f - (transformedPos.y / screenSize.y) * 2.0f; // Y轴翻转
return normalizedPos;
}
注意 Y 轴的翻转,因为 DirectX 的屏幕坐标原点在左上角,而裁剪空间的原点在中心。
仓库
完整的测试代码仓库如下: