1167 字
6 分钟
[.NET] 基于 Silk.NET DirectX 几何着色器的大量四边形渲染

假如我们需要渲染数以万计的四边形, 包括描边颜色和填充颜色, 并且要求高帧率, 那么单纯使用诸如 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 的屏幕坐标原点在左上角,而裁剪空间的原点在中心。

仓库#

完整的测试代码仓库如下:

SlimeNull
/
LibDxGeometryRendering
Waiting for api.github.com...
00K
0K
0K
Waiting...
[.NET] 基于 Silk.NET DirectX 几何着色器的大量四边形渲染
https://slimenull.com/posts/20250623010000/
作者
SlimeNull
发布于
2025-06-23
许可协议
CC BY-NC-SA 4.0