图形管线漫游 2011 第九篇:输出合并、ROP 与颜色缓冲压缩

原文系列:A trip through the Graphics Pipeline 2011, Part 9
本文是对原文的详细中文解读,力求从零开始、通俗易懂。

一、本篇讲什么?

上一篇讲的是"Fork 阶段":把少量输入拆分成大量并行着色任务。
本篇讲"Join 阶段":把大量并行的着色结果,按正确顺序合并回一个线性的内存写入流。
涉及的核心内容:

  • ROP(光栅操作处理器)是什么、做什么
  • Late Z / Stencil 和 Alpha Blend 如何工作
  • DRAM 页与 Tile 大小的关系
  • 颜色缓冲压缩(Color Buffer Compression)
  • 为什么混合(Blend)不能完全可编程(附深入分析)

二、输出合并(Output Merger)的任务

着色器输出的像素结果是乱序的(因为不同 Warp 完成时间不同),但写入帧缓冲必须按 API 提交顺序来,否则结果不正确。
Output Merger 阶段需要做:

着色器输出(乱序)
       |
       v
  [排序缓冲区]  <--- 按图元 ID 重新排序,保证 API 顺序
       |
       v
  Late Z/Stencil 测试(如有必要)
       |
       v
  Alpha Blend(混合)
       |
       v
  写回帧缓冲 / 深度缓冲(含压缩)

两个关键性质:

  • 读-修改-写(Read-Modify-Write):混合需要先读旧值,计算后再写回
  • 顺序敏感:混合和 Z 测试的结果都依赖处理顺序

三、ROP:光栅操作处理器

3.1 ROP 是什么?

ROP 全称有多种说法(Render OutPut unit / Raster Operations Pipeline / Raster Operations Processor),硬件单元,负责管线末端的混合与写入。
历史:ROP 来自 2D 图形加速时代的位块传输(BitBLT)硬件,当时有三路输入:
输出=f(目标像素,源数据,掩码) \text{输出} = f(\text{目标像素}, \text{源数据}, \text{掩码}) 输出=f(目标像素,源数据,掩码)
fff 是某种按位逻辑运算。后来颜色替换了位平面,Alpha 替换了掩码,按位运算变成了混合,但 ROP 这个名字沿用至今(OpenGL 里还保留着 “logic op”,是最后一块活化石)。

3.2 ROP 做什么?

一个 ROP 单元的完整工作流:

从内存读取
当前帧缓冲/深度缓冲
长延迟,可能涉及解压缩

排序缓冲区
按图元 ID 排序
保证 API 顺序

Late Z / Stencil 测试
处理无法 Early Z 的情况

Alpha Blend
固定功能,少量 ALU
乘法 + 乘加 + 减法

写回内存
含压缩处理
长延迟但不阻塞

混合公式(典型 Alpha Blend):
Cout=αsrc⋅Csrc+(1−αsrc)⋅Cdst C_{\text{out}} = \alpha_{\text{src}} \cdot C_{\text{src}} + (1 - \alpha_{\text{src}}) \cdot C_{\text{dst}} Cout​=αsrc​⋅Csrc​+(1−αsrc​)⋅Cdst​
其中 CsrcC_{\text{src}}Csrc​ 是着色器输出的颜色,CdstC_{\text{dst}}Cdst​ 是已有帧缓冲颜色,αsrc\alpha_{\text{src}}αsrc​ 是 Alpha 值。

3.3 为什么 Blend 单元要故意做得简单?

  • Blend 单元需要固定、可预测的延迟(因为必须顺序处理)
  • 晶体管面积(芯片面积)宝贵,应该优先给通用着色器单元(对所有代码都有用),而不是给只在管线末尾用一次的固定功能单元
  • 混合本身计算量不大:每个像素只需要几十个时钟周期

四、DRAM 页与 Tile 大小的关系

4.1 DRAM 的物理结构回顾

DRAM 内部是一个巨大的二维阵列(行×列)。切换行(Row)开销很大,同一行内访问很快。一行典型大小约 4KB。
但 4KB 太大,实际传输以**页(Page)**为单位:
一个 DRAM 页≈512 bits=64 bytes \text{一个 DRAM 页} \approx 512 \text{ bits} = 64 \text{ bytes} 一个 DRAM 页≈512 bits=64 bytes

4.2 一个 DRAM 页能装多少像素?

以 32 位/像素(标准深度缓冲或常见 RT 格式)为例:
64 bytes4 bytes/pixel=16 pixels \frac{64 \text{ bytes}}{4 \text{ bytes/pixel}} = 16 \text{ pixels} 4 bytes/pixel64 bytes​=16 pixels
恰好是一个 4×4 Tile 的像素数!(或者 8×2、2×8 等形状)
这不是巧合——GPU 的 Tile 大小就是围绕 DRAM 页大小设计的,目的是让每次 Tile 的读写都尽量落在同一个 DRAM 页内,最大化内存带宽利用率。

DRAM 页与 Tile 对应关系示意:
屏幕像素布局(线性存储,效率差):
  行0: [p0][p1][p2]...[p1919]   <- 一行 1920 个像素,跨多个 DRAM 页
  行1: [p1920]...
  取一个 4×4 Tile 需要访问 4 个不同 DRAM 行 => 效率很低!
屏幕像素布局(Tile 存储,效率好):
  Tile(0,0): [p00][p10][p01][p11][p20][p30][p21][p31]...  <- 16 像素连续
  Tile(1,0): [p40][p50]...
  取一个 4×4 Tile 只需访问 1 个 DRAM 页 => 效率最高!

这就是为什么帧缓冲在内存中不是线性存储(不是逐行扫描),而是按 Tile 排列(Swizzle 或 Morton 顺序等)。

4.3 预取帧缓冲数据

光栅化确认一个 Tile 有像素后,立即向内存发出预取请求,让 ROP 准备好对应的帧缓冲数据。等着色器算完结果回来时,帧缓冲数据已经在本地缓冲里了,Blend 可以立即执行,无需等待内存延迟。

时间轴(带预取):
光栅化确认 Tile 有像素
  |----> 立即向内存发预取请求(帧缓冲数据)
  |
  v
像素进入着色器(需要数百周期)
  |
  v
着色完成,结果回来
  |
  v
帧缓冲数据已经在 ROP 缓冲里 ---> 立即 Blend,无延迟!

五、颜色缓冲压缩

5.1 为什么需要压缩?

MSAA 开启后(如 4x MSAA),每个像素存 4 个样本,带宽变成原来的 4 倍。这是严重的瓶颈。
深度缓冲压缩(第七篇讲过)用平面方程。颜色数据不适合平面方程,但有另一个特点可以利用:

5.2 MSAA 颜色的特殊性

像素着色器每像素只执行一次(不是每样本一次),所以:

  • 如果一个像素被单个图元完全覆盖:4 个 MSAA 样本的颜色值完全相同
  • 这时只需存储 1 份颜色,用 1 个标志位表明"本像素所有样本颜色相同"
MSAA 4x 颜色压缩示意:
情况 A(图元完全覆盖该像素):
  样本0: RGBA(255,128,0,255)
  样本1: RGBA(255,128,0,255)   <- 4 个样本颜色相同
  样本2: RGBA(255,128,0,255)
  样本3: RGBA(255,128,0,255)
  压缩后: RGBA(255,128,0,255) + flag=1   <- 只存 1 份,节省 75% 带宽!
情况 B(三角形边缘穿过该像素):
  样本0: RGBA(255,128,0,255)  <- 在三角形内
  样本1: RGBA( 30, 30,30,255) <- 在背景上
  样本2: RGBA(255,128,0,255)
  样本3: RGBA( 30, 30,30,255)
  无法压缩,存储 4 份完整颜色

5.3 快速 Clear

和深度缓冲类似,颜色缓冲也有"全 Tile 刚被 Clear"的标志。Clear 时只需:

  • 在 SRAM 里标记该 Tile 为"已 Clear,颜色为 X"
  • 不需要实际写入 Tile 内所有像素的内存
    等到第一次有真实颜色写入该 Tile,才需要更新内存。节省了大量带宽,这就是 GPU Clear 操作极快的原因。

六、为什么混合(Blend)不能完全可编程?

这是本篇最有深度的讨论,原文花了很大篇幅。

6.1 方案一:在像素着色器里读帧缓冲并混合

想法:让着色器直接读当前像素的帧缓冲值,自己算混合,写回。
问题一:无约束读带来竞态
如果允许任意读帧缓冲坐标,着色器 A 可能读到正在被着色器 B 写的位置。由于着色器并行运行,读到的是旧值还是新值?完全不确定!
退一步:只允许读"当前像素位置"的帧缓冲值——这比较合理。
问题二:需要追踪"哪些样本正在被着色"
为了保证两个覆盖同一像素的三角形不同时被着色(否则都读到旧值,后写的把前写的覆盖),需要对每个样本维护一个"正在被着色"的标志位。
需要多少内存?以 1920×1080、8x MSAA 为例:
1920×1080×8 样本=16,588,800 个标志位 1920 \times 1080 \times 8 \text{ 样本} = 16,588,800 \text{ 个标志位} 1920×1080×8 样本=16,588,800 个标志位
≈2 MB 的标志位内存 \approx 2 \text{ MB 的标志位内存} ≈2 MB 的标志位内存
而且这是全局共享的,每次发射新 Quad 前都要查询和更新,直接成为瓶颈。
深度缓冲的 HiZ 标志位只是"提示",可以出错只影响效率;但这里的标志位必须 100% 正确,否则结果错误。
问题三:追踪粒度的困境

  • 按样本追踪:内存需求巨大(如上所述)
  • 按像素追踪:MSAA 边缘处,相邻不重叠三角形共享像素,被迫串行化
    问题四:不能为了一致性而让着色器串行执行
    如果强制所有着色器串行(确保顺序),就彻底失去了并行带来的吞吐量,GPU 的核心优势消失。
方案一的困境总结:
并发着色 -> 竞态 -> 需要追踪 -> 巨量追踪内存 -> 成为瓶颈
串行着色 -> 无竞态 -> 彻底丧失并行性 -> GPU 退化为单核 CPU

6.2 方案二:独立的"混合着色器"(Blend Shader)

想法:在 ROP 里加一个小型可编程单元,专门执行混合计算,有独立的指令集。
问题一:硬件成本高
一个完整的"着色器"需要:ALU + 指令解码器 + 控制逻辑。ROP 本来是精简的固定功能单元,加一个着色器进去面积和功耗都大幅增加。
问题二:低延迟要求与高吞吐设计冲突
普通着色器单元用"多 Warp 切换"来换取高吞吐(容忍高延迟)。但 ROP 这里必须低延迟(要顺序处理),两种设计目标对立,不能复用通用着色器单元。
问题三:流水线长度固定限制了灵活性
为了保证顺序处理,混合单元必须是固定长度流水线。这意味着混合着色器的指令数量存在上限,不能有循环、动态分支——最终和"寄存器组合器"(Register Combiner)差不多,远不是真正意义上的可编程着色器。

两种方案的对比:
方案一(PS 里混合):
  - 竞态问题难解决
  - 追踪内存巨大
  - 影响 MSAA 性能
  - 串行化方案彻底失去并行性
方案二(混合着色器):
  - 硬件成本高(额外 ALU + 控制逻辑)
  - 设计约束多(低延迟、固定流水线)
  - 实际能力有限(像寄存器组合器,不像着色器)
  - API 和硬件都不喜欢

6.3 现实是什么?

目前(2011 年原文写作时)的方案就是:有限的、固定功能的 Blend 单元,支持有限几种预定义的混合模式。不完美,但是:

  • 低延迟,顺序处理有保障
  • 硬件简单,面积小
  • 覆盖了绝大多数实际使用场景
    未来如果要实现真正可编程混合,就必须从根本上解决方案一里的竞态和追踪问题——这正是现代 GPU API(如 Vulkan、DX12 的 ROVs / Rasterizer Order Views)尝试解决的方向,通过 API 层面的明确同步原语来处理。

七、整体管线最终流程

顶点着色器 Vertex Shader

图元装配
Primitive Assembly
严格按 API 顺序

光栅化
Rasterizer ×N
输出 Quad + 覆盖掩码

Early Z / HiZ
提前剔除不可见像素

预取帧缓冲数据
向 ROP 发送预取请求

像素着色器
Pixel Shader
Warp 并行执行

ROP 排序缓冲区
按图元 ID 重排序

Late Z / Stencil
处理无法提前的情况

Alpha Blend
固定功能混合单元

写回帧缓冲
含颜色压缩
Color Buffer Compression

显示输出

八、C++ 完整演示代码

// 演示内容:
//   1. Alpha Blend 计算(固定功能混合单元逻辑)
//   2. ROP 排序缓冲区(按图元顺序合并乱序输出)
//   3. MSAA 颜色压缩(单三角形覆盖时的压缩检测)
//   4. 颜色缓冲快速 Clear 模拟
#include <iostream>
#include <vector>
#include <array>
#include <algorithm>
#include <cstdint>
#include <cassert>
#include <iomanip>
#include <string>
// ============================================================
// 基础数据类型
// ============================================================
// RGBA 颜色,每通道 [0.0, 1.0]
struct Color {
    float r, g, b, a;
    bool operator==(const Color& o) const {
        return r == o.r && g == o.g && b == o.b && a == o.a;
    }
};
// 打印颜色
std::string colorStr(const Color& c) {
    char buf[64];
    std::snprintf(buf, sizeof(buf), "RGBA(%.2f,%.2f,%.2f,%.2f)", c.r, c.g, c.b, c.a);
    return std::string(buf);
}
// ============================================================
// 第一部分:Alpha Blend(固定功能混合单元)
// ============================================================
// 混合模式枚举(对应 GPU 支持的固定混合模式)
enum class BlendMode {
    OPAQUE,          // 完全不透明,直接覆盖
    ALPHA_BLEND,     // 标准 Alpha 混合
    ADDITIVE,        // 加法混合(粒子特效常用)
    PREMULT_ALPHA,   // 预乘 Alpha 混合
};
// 固定功能混合计算
// 参数:
//   src   = 着色器输出(新像素颜色)
//   dst   = 帧缓冲中已有颜色
//   mode  = 混合模式
// 返回:混合后的颜色
Color alphaBlend(const Color& src, const Color& dst, BlendMode mode) {
    Color out = {};
    switch (mode) {
    case BlendMode::OPAQUE:
        // 直接覆盖,忽略 dst
        out = src;
        break;
    case BlendMode::ALPHA_BLEND:
        // C_out = alpha_src * C_src + (1 - alpha_src) * C_dst
        // 注意:每个通道独立计算
        out.r = src.a * src.r + (1.0f - src.a) * dst.r;
        out.g = src.a * src.g + (1.0f - src.a) * dst.g;
        out.b = src.a * src.b + (1.0f - src.a) * dst.b;
        out.a = src.a + (1.0f - src.a) * dst.a;
        break;
    case BlendMode::ADDITIVE:
        // C_out = C_src + C_dst (用于发光、粒子)
        out.r = std::min(1.0f, src.r + dst.r);
        out.g = std::min(1.0f, src.g + dst.g);
        out.b = std::min(1.0f, src.b + dst.b);
        out.a = std::min(1.0f, src.a + dst.a);
        break;
    case BlendMode::PREMULT_ALPHA:
        // 预乘 Alpha:C_src 已经包含了 alpha 因子
        // C_out = C_src + (1 - alpha_src) * C_dst
        out.r = src.r + (1.0f - src.a) * dst.r;
        out.g = src.g + (1.0f - src.a) * dst.g;
        out.b = src.b + (1.0f - src.a) * dst.b;
        out.a = src.a + (1.0f - src.a) * dst.a;
        break;
    }
    return out;
}
// ============================================================
// 第二部分:ROP 排序缓冲区(乱序输入,按图元顺序输出)
// ============================================================
// 一个着色完成的 Quad 结果
struct ShadedQuad {
    int primitiveId;   // 图元 ID(决定排序顺序)
    int quadX, quadY;  // Quad 在屏幕上的位置
    Color color;       // 着色结果
    bool valid;        // 是否有效
};
// ROP 排序缓冲区
// 功能:接收乱序到达的 ShadedQuad,按 primitiveId 顺序依次输出
class RopSortBuffer {
public:
    // 待输出的下一个图元 ID
    int nextExpected = 0;
    // 乱序缓冲区(最多缓存 MAX_PENDING 个)
    static const int MAX_PENDING = 16;
    std::vector<ShadedQuad> pending;
    // 向缓冲区提交一个着色结果(可以乱序到达)
    void submit(const ShadedQuad& quad) {
        pending.push_back(quad);
        std::cout << "  [提交] primitiveId=" << quad.primitiveId
                  << " Quad(" << quad.quadX << "," << quad.quadY << ")"
                  << " 颜色=" << colorStr(quad.color) << "\n";
    }
    // 尝试按顺序取出下一个可以输出的 Quad
    // 返回 true 表示成功取出,quad 包含结果
    bool tryDequeue(ShadedQuad& outQuad) {
        for (auto it = pending.begin(); it != pending.end(); ++it) {
            if (it->primitiveId == nextExpected) {
                outQuad = *it;
                pending.erase(it);
                nextExpected++;
                return true;
            }
        }
        return false; // 还没有到该输出的 Quad
    }
    // 持续输出直到无法继续
    void flush(std::vector<ShadedQuad>& orderedOutput) {
        ShadedQuad q;
        while (tryDequeue(q)) {
            std::cout << "  [输出] primitiveId=" << q.primitiveId
                      << " Quad(" << q.quadX << "," << q.quadY << ")\n";
            orderedOutput.push_back(q);
        }
        if (!pending.empty()) {
            std::cout << "  [等待] 缓冲中还有 " << pending.size()
                      << " 个乱序 Quad,等待 primitiveId=" << nextExpected << "\n";
        }
    }
};
// ============================================================
// 第三部分:MSAA 颜色压缩
// ============================================================
const int MSAA_SAMPLES = 4; // 4x MSAA
// 一个像素的 MSAA 样本存储
struct MsaaPixel {
    std::array<Color, MSAA_SAMPLES> samples;
    bool compressed;      // 是否处于压缩状态(所有样本颜色相同)
    Color compressedVal;  // 压缩状态下存储的单一颜色值
};
// 检测是否可以压缩(所有样本颜色相同)
bool canCompress(const MsaaPixel& pixel) {
    const Color& ref = pixel.samples[0];
    for (int i = 1; i < MSAA_SAMPLES; i++) {
        if (!(pixel.samples[i] == ref)) return false;
    }
    return true;
}
// 写入一组 MSAA 样本(模拟 ROP 写回时的压缩检测)
void writeMsaaPixel(MsaaPixel& pixel, const std::array<Color, MSAA_SAMPLES>& newSamples) {
    pixel.samples = newSamples;
    if (canCompress(pixel)) {
        pixel.compressed    = true;
        pixel.compressedVal = newSamples[0];
        std::cout << "  => 可以压缩!存储单一颜色 " << colorStr(pixel.compressedVal) << "\n";
    } else {
        pixel.compressed = false;
        std::cout << "  => 无法压缩,存储 " << MSAA_SAMPLES << " 个独立样本\n";
    }
}
// 读取 MSAA 像素(模拟解压缩)
std::array<Color, MSAA_SAMPLES> readMsaaPixel(const MsaaPixel& pixel) {
    if (pixel.compressed) {
        // 解压:把压缩的单一颜色展开为所有样本
        std::array<Color, MSAA_SAMPLES> result;
        result.fill(pixel.compressedVal);
        return result;
    }
    return pixel.samples;
}
// ============================================================
// 第四部分:快速 Color Clear 模拟
// ============================================================
const int TILE_COUNT = 4; // 演示用 4 个 Tile
// 帧缓冲 Tile 的状态
struct FbTile {
    bool justCleared;      // 是否处于"刚被 Clear"状态(节省实际写入)
    Color clearColor;      // Clear 时的颜色
    std::vector<Color> pixels; // 实际像素数据(只有在有真实写入后才分配)
    int pixelCount;
    FbTile(int count) : justCleared(false), pixelCount(count), pixels(count) {}
};
// 模拟快速 Clear(只更新标志,不写实际内存)
void fastClear(std::vector<FbTile>& tiles, const Color& clearColor) {
    std::cout << "  [快速 Clear] 颜色=" << colorStr(clearColor) << "\n";
    for (auto& tile : tiles) {
        tile.justCleared = true;
        tile.clearColor  = clearColor;
        // 注意:不写 tile.pixels!只改标志位
    }
    std::cout << "  => 只更新标志位,零内存写入!\n";
}
// 向 Tile 写入像素(第一次写入时才真正分配/写内存)
void writePixel(FbTile& tile, int idx, const Color& color) {
    if (tile.justCleared) {
        // 第一次有真实颜色写入:先把 Clear 颜色填充进去
        std::cout << "  [首次写入 Tile] 先填充 Clear 颜色,再写新像素\n";
        std::fill(tile.pixels.begin(), tile.pixels.end(), tile.clearColor);
        tile.justCleared = false;
    }
    tile.pixels[idx] = color;
}
// ============================================================
// 主函数
// ============================================================
int main() {
    std::cout << "=== 图形管线第九篇:ROP 与输出合并演示 ===\n\n";
    // --- 演示 1:Alpha Blend ---
    std::cout << "--- 1. Alpha Blend 固定功能混合 ---\n";
    Color background = {0.2f, 0.2f, 0.2f, 1.0f};  // 深灰色背景
    Color sprite     = {1.0f, 0.5f, 0.0f, 0.6f};  // 橙色半透明精灵
    Color blended = alphaBlend(sprite, background, BlendMode::ALPHA_BLEND);
    std::cout << "背景:    " << colorStr(background) << "\n";
    std::cout << "精灵:    " << colorStr(sprite) << "\n";
    std::cout << "混合后:  " << colorStr(blended) << "\n";
    Color particle = {0.8f, 0.9f, 1.0f, 0.4f};  // 淡蓝色粒子
    Color addResult = alphaBlend(particle, blended, BlendMode::ADDITIVE);
    std::cout << "\n粒子(加法混合): " << colorStr(particle) << "\n";
    std::cout << "叠加结果:       " << colorStr(addResult) << "\n";
    // --- 演示 2:ROP 排序缓冲区 ---
    std::cout << "\n--- 2. ROP 排序缓冲区(乱序输入,顺序输出)---\n";
    RopSortBuffer ropBuf;
    // 模拟着色器乱序完成:primitiveId=2 先到,0 后到,1 最后
    std::cout << "\n[提交阶段] 着色器乱序完成:\n";
    ropBuf.submit({2, 4, 4, {0.0f, 0.0f, 1.0f, 1.0f}, true}); // 蓝色,先完成
    ropBuf.submit({0, 0, 0, {1.0f, 0.0f, 0.0f, 1.0f}, true}); // 红色
    ropBuf.submit({1, 2, 2, {0.0f, 1.0f, 0.0f, 1.0f}, true}); // 绿色,最后完成
    std::cout << "\n[输出阶段] 按图元 ID 顺序输出:\n";
    std::vector<ShadedQuad> ordered;
    ropBuf.flush(ordered);
    std::cout << "\n最终输出顺序:";
    for (const auto& q : ordered)
        std::cout << "prim" << q.primitiveId << " ";
    std::cout << "\n";
    // --- 演示 3:MSAA 颜色压缩 ---
    std::cout << "\n--- 3. MSAA 颜色缓冲压缩 ---\n";
    MsaaPixel pxFullCover;
    pxFullCover.compressed = false;
    // 情况 A:三角形完全覆盖,4 个样本颜色相同
    Color solidColor = {1.0f, 0.3f, 0.1f, 1.0f};
    std::array<Color, MSAA_SAMPLES> sameSamples;
    sameSamples.fill(solidColor);
    std::cout << "像素 A(完全覆盖)写入:\n";
    writeMsaaPixel(pxFullCover, sameSamples);
    MsaaPixel pxEdge;
    pxEdge.compressed = false;
    // 情况 B:三角形边缘,4 个样本颜色不同
    Color bg    = {0.1f, 0.1f, 0.1f, 1.0f};
    Color tri   = {0.9f, 0.7f, 0.2f, 1.0f};
    std::array<Color, MSAA_SAMPLES> mixedSamples = {tri, bg, tri, bg};
    std::cout << "\n像素 B(三角形边缘)写入:\n";
    writeMsaaPixel(pxEdge, mixedSamples);
    // --- 演示 4:快速 Color Clear ---
    std::cout << "\n--- 4. 快速 Color Clear ---\n";
    std::vector<FbTile> framebuffer;
    for (int i = 0; i < TILE_COUNT; i++)
        framebuffer.emplace_back(16); // 每个 Tile 16 个像素
    // 执行快速 Clear
    Color skyBlue = {0.53f, 0.81f, 0.98f, 1.0f};
    fastClear(framebuffer, skyBlue);
    // 向 Tile 0 写入第一个像素(触发真实内存初始化)
    std::cout << "\n向 Tile 0 写入像素 [3]:\n";
    writePixel(framebuffer[0], 3, {1.0f, 0.0f, 0.0f, 1.0f});
    // Tile 1 未被任何像素写入,仍然是 Clear 状态
    std::cout << "\nTile 1 状态:" << (framebuffer[1].justCleared ? "仍在 Clear 状态,无内存写入" : "已有真实像素") << "\n";
    std::cout << "\n=== 演示结束 ===\n";
    return 0;
}

编译运行命令:

g++ -std=c++17 -O2 -o rop_demo rop_demo.cpp && ./rop_demo

九、为什么混合不能完全可编程:决策树

想要完全可编程混合

方案一
在 PS 里读帧缓冲

方案二
独立混合着色器

问题:并发读写竞态

解决方案

按样本追踪
Busy 标志位

强制串行执行

需要 2MB 全局追踪内存
成为调度瓶颈
MSAA 性能崩溃

丧失 GPU 并行性
退化为单核 CPU

问题:ROP 需要低延迟
无法复用通用着色器

需要独立 ALU
面积和功耗大增

流水线长度固定
指令数受限
实际接近寄存器组合器

现实方案:
有限固定功能混合单元

十、关键概念总结


概念一句话解释
ROP管线末端的光栅操作处理器,负责混合、Late Z、写回帧缓冲
Alpha Blend固定功能混合:Cout=α⋅Csrc+(1−α)⋅CdstC_{out} = \alpha \cdot C_{src} + (1-\alpha) \cdot C_{dst}Cout​=α⋅Csrc​+(1−α)⋅Cdst​
排序缓冲区ROP 内缓冲乱序到达的 Quad,按图元 ID 重新排序后输出
DRAM 页约 64 字节 = 16 像素(32bit),Tile 大小对应于此
帧缓冲 Tile 存储像素不按行存储,按 Tile 打包,配合 DRAM 页最大化带宽
颜色缓冲压缩MSAA 下完全覆盖的像素 4 个样本颜色相同,只存 1 份
快速 Clear只更新标志位,不写实际像素内存
可编程混合的困难竞态+追踪开销(方案一) 或 低延迟硬件约束(方案二)

十一、通俗比喻

ROP 排序缓冲区 = 快递分拣中心的传送带:

包裹(着色结果)不按顺序到达,但必须按编号顺序装车发货(写帧缓冲)。分拣中心设有暂存区(排序缓冲),等"3 号包裹"到了,把已经来了的"4、5、6 号"一起发出。
DRAM 页与 Tile = 书的章节和印刷:
如果把像素比作文字,线性存储就是把第 1 行第 1 列到第 1 行第 1920 列全放一起——取一个小区域时要翻很多页。Tile 存储把 8×8 的小块放在一起,就像把同一段落的内容印在同一页,查阅(访存)效率大大提高。
颜色缓冲压缩 = 固态硬盘的重复数据消除:
“这个 Tile 里 16 个像素都是同一个颜色”——那就只存一份,附上一个"全相同"的标志。下次读时自动展开。节省带宽就像 SSD 节省存储空间。
为什么不能有完全可编程混合 = 为什么高速公路收费站不能改成"随便通行,事后对账":
如果所有车同时过收费站不交费,事后对账非常复杂,而且谁先谁后会有争议。强制一辆一辆过就没问题,但堵死了。现实就是定几种固定收费方案(混合模式),既有序又不会堵死。
下一篇将介绍:几何着色器(Geometry Shader)以及相关管线变体。

图形管线漫游 2011 第十篇:几何着色器(Geometry Shader)

原文系列:A trip through the Graphics Pipeline 2011, Part 10
本文是对原文的详细中文解读,力求从零开始、通俗易懂。

一、本篇讲什么?

本篇介绍 D3D10 带来的最显眼的新特性:几何着色器(Geometry Shader,简称 GS)。
GS 夹在顶点着色器(VS)和光栅化之间,它与 VS/PS 最大的不同是:

  • VS:1 个顶点进 → 1 个顶点出(1:1)
  • GS:1 个图元进 → 0 到 N 个顶点出(1:可变)
    这个"可变"带来了很多复杂性,也带来了很多性能问题。

二、着色器阶段的通用模式

原文先做了一个重要的总结:所有着色器阶段(VS、PS、GS……)在硬件层面的结构都是类似的:

输入缓冲区(排队等待)
      |
      v
调度逻辑(Dispatch,将工作发给着色器单元)
      |
      v
大量着色器单元并行执行(Go Wide)
      |
      v
输出缓冲区(乱序结果到这里)
      |
      v
重排序(按 API 顺序重新排列)
      |
      v
下一个管线阶段

VS 和 PS 我们已经详细看过了。GS 遵循同样的模式,只是输入输出的"形状"不同,且复杂得多。

三、GS 在管线中的位置

输入装配器
Input Assembler

顶点着色器
Vertex Shader

图元装配
Primitive Assembly
第一次 PA

几何着色器
Geometry Shader
图元进,顶点出

第二次图元装配
Second PA
Strip 重建图元

视口变换
消耗 VPAI

裁剪/剔除
Clip/Cull

光栅化
Rasterizer

像素着色器
Pixel Shader
消耗 RTAI

注意 GS 的存在导致管线里出现了两次图元装配(PA)——这是 GS 带来的额外开销之一。

四、GS 的输入:麻烦从这里开始

4.1 GS 操作的是图元,不是顶点

VS 每次处理一个顶点,很容易打包成大批次(比如 32 个顶点一批)。
GS 每次处理一个图元(点/线/三角形/带邻接信息的图元),而每个图元包含多个顶点:

图元类型顶点数
点(Point)1
线(Line)2
三角形(Triangle)3
带邻接的线(Line with Adjacency)4
带邻接的三角形(Triangle with Adjacency)6
控制点 Patch(D3D11)最多 32

每个 GS 调用需要的顶点数更多,内存消耗更大。

4.2 批次大小难以控制

VS 可以轻松凑够 32 个顶点一批。GS 的批次大小取决于顶点缓存命中率:

  • 顶点缓存命中率高(共享顶点多)→ 每批图元数可能有 40 个(三角形情况下)
  • 顶点缓存命中率低 → 每批可能只有 5 个图元(带邻接三角形情况下)
    批次太小 → 着色器单元利用率低 → 性能差。

4.3 输入格式:索引 + 顶点块

为了节省内存,GS 输入不重复存储共享顶点,而是:

一批 GS 输入 =
  VS 输出的顶点块(如 32 个顶点)
  +
  一个小索引缓冲区(每个图元记录它用到哪几个顶点的索引)

每个索引只需 5 位(因为索引到 32 个顶点范围内),非常紧凑。

五、GS 的输出:更复杂

5.1 可变数量输出

GS 可以输出 0 到 Nmax⁡N_{\max}Nmax​ 个顶点,Nmax⁡N_{\max}Nmax​ 在编译时固定。
输出的顶点数量运行时可变,这使得下游管线无法提前知道要处理多少图元。
这带来了一个问题:
并行 GS 调用数∝可用缓冲区大小Nmax⁡×每顶点属性大小 \text{并行 GS 调用数} \propto \frac{\text{可用缓冲区大小}}{N_{\max} \times \text{每顶点属性大小}} 并行 GS 调用数∝Nmax​×每顶点属性大小可用缓冲区大小​
Nmax⁡N_{\max}Nmax​ 越大,每次 GS 调用占用缓冲区越多,可并行的调用数越少,延迟隐藏能力越弱。

5.2 GS 输出顶点,但光栅化需要图元

GS 输出的是顶点流,不是图元。为了让光栅化器能处理,必须把顶点流重新解释为图元。
解决方案是Strip 格式:

Triangle Strip(三角带)示意:
顶点:  v0  v1  v2  v3  v4  v5
        |   |   |   |   |   |
三角形:  [v0,v1,v2] [v1,v2,v3] [v2,v3,v4] [v3,v4,v5]
         (每三个连续顶点构成一个三角形,交替翻转法线)
优点:4 个顶点表示 2 个三角形(节省 1/3 的顶点存储)

Strip 格式的好处:

  • 第二次 PA(图元装配)实现极简:只需顺序读 3 个顶点 + 跟踪当前法线方向
  • 节省输出缓冲区空间(比起每个三角形独立存 3 个顶点)
  • 支持"Restart Strip"标志位(一个 bit/顶点),用于输出多个不连续的图元
    如果需要输出复杂几何(如拉伸体),可能需要多个 Strip Restart 标志,有一定不便。

5.3 API 顺序的重新排序

与 VS 不同,GS 输出的图元数量不确定,第二次 PA 必须先扫描输出缓冲,找到每个完整图元的起始位置,再发给裁剪/剔除/三角形设置。

GS 调用顺序要求:
  先输出 GS 调用 #0 的所有图元
  再输出 GS 调用 #1 的所有图元
  ...
  再输出 GS 调用 #N 的所有图元
  (批次间同样要有序)
=> 即使 GS #3 先完成,也必须等待 #0、#1、#2 的输出确定后才能向下发送。

六、GS 的性能问题汇总

GS 是一个在设计上存在多重性能陷阱的管线阶段:

问题一:输入批次小,利用率低
  原因:顶点缓存命中率决定每批图元数,无法完全控制
  后果:Warp 内只有少量活跃线程,浪费 ALU
问题二:输出变长,并行度受限
  原因:N_max 大则每个 GS 占用缓冲多,能并行的调用少
  后果:难以充分隐藏延迟,频繁 Stall
问题三:第二次图元装配,额外开销
  原因:GS 输出顶点而非图元,必须再组装一次
  后果:多了一个缓冲+扫描阶段
问题四:即使 Pass-Through GS,也有 3x~7x 开销
  测量:D3D10 首代硬件上,纯透传 GS 比无 GS 慢 3~7 倍
  原因:两次缓冲、两次 PA、低利用率的着色器批次

结论:不需要 GS 的时候,一定不要用 GS(哪怕只是透传)。

七、VPAI 和 RTAI

这两个是 GS 引入的额外功能,不影响 GS 本身的执行,只影响下游:

功能全称作用在哪里被消费
VPAIViewport Array Index每个图元选择一个视口和裁剪矩形视口变换阶段
RTAIRender-target Array Index每个图元选择写入哪个纹理数组层像素管线写回阶段

RTAI 典型用例:一次 Draw Call 渲染立方体贴图的 6 个面。GS 把每个三角形复制最多 6 次,每次设置不同的 RTAI(0~5 对应 6 个面),光栅化和像素着色分别写入 6 个渲染目标。
VPAI 典型用例:层叠阴影贴图(Cascaded Shadow Maps)。设置多个视口(对应不同距离的级联),GS 把几何体分配给对应的视口一次 Draw Call 完成所有级联渲染。
两者都只是在 GS 输出的顶点上贴一个"标签",由"主顶点"(Leading Vertex,每个图元的第一个顶点)决定整个图元的标签值。

八、GS Instancing(D3D11 新增)

8.1 解决低利用率问题

假设 GS 的主要工作是"对每个输入图元,处理 6 个面(立方体贴图)",内部有一个循环:

// 未优化的 GS(伪代码)
for (int face = 0; face < 6; face++) {
    if (isVisible(tri, face)) {
        EmitVertex(..., face);
        // ...
    }
}

6 次循环是串行的,但 6 次计算其实完全独立。

8.2 GS Instancing 的做法

用 [instance(N)] 声明,让硬件为每个输入图元自动生成 N 个独立的 GS 调用,每次调用拿到一个 SV_GSInstanceID:

// 使用 GS Instancing 的版本(伪代码)
[maxvertexcount(4)]
[instance(6)]        // 每个图元生成 6 个独立调用
void GS_main(
    triangle VS_OUT input[3],
    uint faceIndex : SV_GSInstanceID,  // 当前是第几个实例
    inout TriangleStream<GS_OUT> outStream)
{
    // 直接处理 faceIndex 对应的面,不需要循环
    if (isVisible(input, faceIndex)) {
        EmitVertex(..., faceIndex);
        // ...
    }
}

效果:原来 1 个 GS 调用(串行 6 次循环)变成 6 个独立的 GS 调用,可以并行分配给不同的着色器单元,批次大小增加 6 倍,利用率大幅提升。

8.3 利用率对比

不使用 GS Instancing:
  输入:11 个图元 -> 11 个 GS 调用
  每个 GS 调用:串行处理 6 个面
  Warp 大小:32 线程 -> 11 个线程活跃,21 个空闲
  利用率 ≈ 34%
使用 GS Instancing(instance=6):
  输入:11 个图元 -> 66 个 GS 调用
  每个 GS 调用:处理 1 个面
  Warp 大小:32 线程 -> 至少 2 个 Warp,利用率大幅提升
  利用率明显更高

九、GS 管线完整流程

VS 输出顶点块
+ 索引缓冲

GS 输入缓冲区
按图元打包
可能合并多个 VS 输出块

调度逻辑
Dispatch
每图元 x Instance 数

着色器单元并行执行
GS 调用
输出 0..N_max 个顶点

GS 输出缓冲区
乱序结果

按 API 顺序重排
扫描确定图元边界

第二次图元装配
Strip 解析

视口变换
消耗 VPAI

裁剪/剔除

三角形设置
光栅化...

十、C++ 完整演示代码

// 演示内容:
//   1. GS 输入图元的内存布局(索引 + 顶点块)
//   2. Triangle Strip 图元重建逻辑(第二次 PA)
//   3. GS Instancing 利用率对比模拟
//   4. VPAI / RTAI 标签附加与消费演示
#include <iostream>
#include <vector>
#include <array>
#include <string>
#include <cassert>
#include <cstdint>
#include <algorithm>
// ============================================================
// 基础数据结构
// ============================================================
// 顶点(简化,只有位置和一个属性)
struct Vertex {
    float x, y, z;     // 位置
    float u, v;         // 纹理坐标
};
// 打印顶点
std::string vertStr(const Vertex& v) {
    char buf[64];
    std::snprintf(buf, sizeof(buf), "V(%.1f,%.1f,%.1f)", v.x, v.y, v.z);
    return std::string(buf);
}
// GS 输出顶点(带额外的 VPAI/RTAI 标签)
struct GsOutputVertex {
    Vertex  pos;
    int     viewportIndex;    // VPAI,-1 表示不使用
    int     renderTargetIndex; // RTAI,-1 表示不使用
    bool    stripRestart;     // true = 从这里开始新的 Strip
};
// ============================================================
// 第一部分:GS 输入图元内存布局
// ============================================================
// 模拟一个 VS 输出顶点块
struct VsOutputBlock {
    static const int MAX_VERTS = 32;
    std::array<Vertex, MAX_VERTS> verts;
    int count = 0;
    void addVertex(const Vertex& v) {
        assert(count < MAX_VERTS);
        verts[count++] = v;
    }
};
// 一个 GS 输入图元(引用 VS 输出块中的顶点)
struct GsInputPrimitive {
    const VsOutputBlock* block;         // 指向顶点块(不重复存储顶点!)
    std::array<uint8_t, 6> indices;     // 顶点索引(最多 6 个,带邻接三角形)
    int indexCount;                     // 实际使用的索引数
    int primitiveId;                    // 图元编号(用于保序)
    // 获取第 i 个顶点
    const Vertex& getVertex(int i) const {
        assert(i < indexCount);
        return block->verts[indices[i]];
    }
};
// 打印 GS 输入图元
void printGsInput(const GsInputPrimitive& prim) {
    std::cout << "  图元 #" << prim.primitiveId << "(" << prim.indexCount << " 个顶点):";
    for (int i = 0; i < prim.indexCount; i++) {
        std::cout << vertStr(prim.getVertex(i));
        if (i < prim.indexCount - 1) std::cout << " -> ";
    }
    std::cout << "\n";
}
// ============================================================
// 第二部分:Triangle Strip 重建(第二次 PA)
// ============================================================
// 一个三角形(三个顶点)
struct Triangle {
    std::array<GsOutputVertex, 3> verts;
};
// 从 Triangle Strip 顶点流重建三角形列表
// Strip 规则:
//   奇数位置的三角形(v1,v2,v3)法线反转,需要把 v1 和 v2 交换保持正面
//   Strip Restart 标志重置计数器
std::vector<Triangle> rebuildFromStrip(const std::vector<GsOutputVertex>& strip) {
    std::vector<Triangle> triangles;
    // 当前 Strip 内的顶点缓冲(滑动窗口:最多保留最近 2 个)
    std::vector<GsOutputVertex> window;
    int indexInStrip = 0;  // 当前 Strip 内的第几个顶点
    for (const auto& v : strip) {
        if (v.stripRestart) {
            // 遇到 Restart 标志:清空窗口,开始新 Strip
            window.clear();
            indexInStrip = 0;
        }
        window.push_back(v);
        if ((int)window.size() >= 3) {
            Triangle tri;
            // 取最近 3 个顶点
            int n = (int)window.size();
            GsOutputVertex& a = window[n - 3];
            GsOutputVertex& b = window[n - 2];
            GsOutputVertex& c = window[n - 1];
            // 偶数三角形(indexInStrip % 2 == 0):顺序 a,b,c
            // 奇数三角形(indexInStrip % 2 == 1):交换 a,b 保持法线一致
            if ((indexInStrip - 2) % 2 == 0) {
                tri.verts = {a, b, c};
            } else {
                tri.verts = {b, a, c};  // 交换前两个顶点,翻转三角形朝向
            }
            triangles.push_back(tri);
        }
        indexInStrip++;
    }
    return triangles;
}
// ============================================================
// 第三部分:GS Instancing 利用率对比
// ============================================================
// 模拟一次 GS 批次的利用率
// numPrimitives:批次中的图元数
// instanceCount:每图元的 GS 实例数
// warpSize:Warp 大小(通常 32)
void analyzeGsUtilization(int numPrimitives, int instanceCount, int warpSize) {
    int totalInvocations = numPrimitives * instanceCount;
    // 需要几个 Warp 来承载这些调用
    int numWarps = (totalInvocations + warpSize - 1) / warpSize;
    // 最后一个 Warp 的利用率(其余 Warp 满负荷)
    int lastWarpActive = totalInvocations % warpSize;
    if (lastWarpActive == 0) lastWarpActive = warpSize;
    float avgUtil = (float)totalInvocations / (float)(numWarps * warpSize) * 100.0f;
    std::cout << "  图元数=" << numPrimitives
              << ", Instance 数=" << instanceCount
              << " => GS 调用总数=" << totalInvocations
              << ", 需要 " << numWarps << " 个 Warp"
              << ", 平均利用率=" << avgUtil << "%\n";
}
// ============================================================
// 第四部分:VPAI / RTAI 标签演示
// ============================================================
// 模拟一个立方体贴图渲染 GS(使用 RTAI + Instancing)
// 输入:一个三角形
// 为 6 个面分别生成(如果可见的话)带不同 RTAI 的三角形
struct CubeFace {
    int index;
    std::string name;
};
// 简化的"可见性"判断(实际需要法向量计算,这里用位置做粗略判断)
bool isVisibleOnFace(const GsInputPrimitive& prim, int faceIndex) {
    // 简化逻辑:根据 Z 坐标判断面朝向
    float avgZ = 0.0f;
    for (int i = 0; i < prim.indexCount; i++)
        avgZ += prim.getVertex(i).z;
    avgZ /= prim.indexCount;
    // face 0=+X, 1=-X, 2=+Y, 3=-Y, 4=+Z, 5=-Z
    // 简化:只有一半面可见
    switch (faceIndex) {
        case 4: return avgZ > 0;   // +Z 面:Z 为正时可见
        case 5: return avgZ <= 0;  // -Z 面:Z 为负时可见
        default: return true;      // 其他面简化为都可见
    }
}
// 模拟立方体贴图 GS(每个面 1 个 instance)
std::vector<GsOutputVertex> cubemapGS(
    const GsInputPrimitive& prim,
    int faceIndex)   // 这就是 SV_GSInstanceID
{
    static const std::array<std::string, 6> faceNames = {
        "+X", "-X", "+Y", "-Y", "+Z", "-Z"
    };
    std::vector<GsOutputVertex> output;
    if (!isVisibleOnFace(prim, faceIndex)) {
        // 不可见:输出 0 个顶点(GS 可以什么都不发射)
        std::cout << "    Face " << faceNames[faceIndex] << ": 不可见,跳过\n";
        return output;
    }
    std::cout << "    Face " << faceNames[faceIndex] << ": 可见,发射三角形 RTAI=" << faceIndex << "\n";
    // 为该面发射顶点
    for (int i = 0; i < prim.indexCount; i++) {
        GsOutputVertex v;
        v.pos              = prim.getVertex(i);
        v.viewportIndex    = faceIndex;  // VPAI:使用对应面的视口
        v.renderTargetIndex = faceIndex; // RTAI:写入对应的纹理数组层
        v.stripRestart     = (i == 0);   // 第一个顶点开始新 Strip
        output.push_back(v);
    }
    return output;
}
// ============================================================
// 主函数
// ============================================================
int main() {
    std::cout << "=== 图形管线第十篇:几何着色器演示 ===\n\n";
    // --- 演示 1:GS 输入图元内存布局 ---
    std::cout << "--- 1. GS 输入图元(索引引用顶点块,不重复存储)---\n";
    VsOutputBlock block;
    // 添加几个顶点(模拟 VS 的输出)
    block.addVertex({0.0f, 0.0f, 0.0f, 0.0f, 0.0f});   // 顶点 0
    block.addVertex({1.0f, 0.0f, 0.0f, 1.0f, 0.0f});   // 顶点 1
    block.addVertex({0.5f, 1.0f, 0.0f, 0.5f, 1.0f});   // 顶点 2
    block.addVertex({1.5f, 1.0f, 0.0f, 1.5f, 1.0f});   // 顶点 3
    // 两个三角形,共享顶点 1 和 2(顶点 1、2 只存储一次)
    GsInputPrimitive tri0;
    tri0.block = &block;
    tri0.indices = {0, 1, 2, 0, 0, 0};
    tri0.indexCount = 3;
    tri0.primitiveId = 0;
    GsInputPrimitive tri1;
    tri1.block = &block;
    tri1.indices = {1, 3, 2, 0, 0, 0};
    tri1.indexCount = 3;
    tri1.primitiveId = 1;
    std::cout << "顶点块中存储 " << block.count << " 个顶点(共享顶点不重复):\n";
    for (int i = 0; i < block.count; i++) {
        std::cout << "  [" << i << "] " << vertStr(block.verts[i]) << "\n";
    }
    std::cout << "GS 输入图元(通过索引引用):\n";
    printGsInput(tri0);
    printGsInput(tri1);
    // --- 演示 2:Triangle Strip 重建 ---
    std::cout << "\n--- 2. Triangle Strip 重建(第二次 PA)---\n";
    // 模拟 GS 输出:一个包含 4 个顶点的 Triangle Strip(产生 2 个三角形)
    // v0 -> v1 -> v2 -> v3
    // 三角形:(v0,v1,v2) 和 (v2,v1,v3)(注意奇数三角形要交换前两顶点)
    std::vector<GsOutputVertex> stripOutput;
    for (int i = 0; i < 4; i++) {
        GsOutputVertex v;
        v.pos = {(float)i, 0.0f, 0.0f, 0.0f, 0.0f};
        v.viewportIndex     = 0;
        v.renderTargetIndex = 0;
        v.stripRestart      = (i == 0);  // 第一个顶点是 Strip 起始
        stripOutput.push_back(v);
    }
    // 追加第二个 Strip(带 Restart 标志)
    for (int i = 0; i < 3; i++) {
        GsOutputVertex v;
        v.pos = {(float)i * 0.5f, 1.0f, 0.0f, 0.0f, 0.0f};
        v.viewportIndex     = 0;
        v.renderTargetIndex = 1;
        v.stripRestart      = (i == 0);  // 新 Strip 的起点
        stripOutput.push_back(v);
    }
    auto triangles = rebuildFromStrip(stripOutput);
    std::cout << "Strip 输出 " << stripOutput.size() << " 个顶点,重建出 "
              << triangles.size() << " 个三角形:\n";
    for (int i = 0; i < (int)triangles.size(); i++) {
        const auto& tri = triangles[i];
        std::cout << "  三角形 " << i << ":";
        for (const auto& v : tri.verts)
            std::cout << vertStr(v.pos) << " ";
        std::cout << "(RTAI=" << tri.verts[0].renderTargetIndex << ")\n";
    }
    // --- 演示 3:GS Instancing 利用率对比 ---
    std::cout << "\n--- 3. GS Instancing 利用率对比 ---\n";
    int warpSize = 32;
    std::cout << "Warp 大小 = " << warpSize << "\n";
    std::cout << "不使用 GS Instancing(每图元 1 个调用):\n";
    for (int np : {5, 11, 32, 50}) {
        analyzeGsUtilization(np, 1, warpSize);
    }
    std::cout << "使用 GS Instancing(instance=6,用于立方体贴图):\n";
    for (int np : {5, 11, 32, 50}) {
        analyzeGsUtilization(np, 6, warpSize);
    }
    // --- 演示 4:RTAI 立方体贴图 GS 模拟 ---
    std::cout << "\n--- 4. RTAI 立方体贴图 GS(一个三角形 -> 6 个面)---\n";
    // 创建一个在 Z>0 侧的三角形
    VsOutputBlock cubeBlock;
    cubeBlock.addVertex({0.0f, 0.0f,  1.0f, 0.0f, 0.0f});  // Z>0
    cubeBlock.addVertex({1.0f, 0.0f,  1.0f, 1.0f, 0.0f});
    cubeBlock.addVertex({0.5f, 1.0f,  1.0f, 0.5f, 1.0f});
    GsInputPrimitive cubeTri;
    cubeTri.block       = &cubeBlock;
    cubeTri.indices     = {0, 1, 2, 0, 0, 0};
    cubeTri.indexCount  = 3;
    cubeTri.primitiveId = 0;
    std::cout << "输入三角形:" << vertStr(cubeTri.getVertex(0))
              << " " << vertStr(cubeTri.getVertex(1))
              << " " << vertStr(cubeTri.getVertex(2)) << "\n";
    std::cout << "对 6 个面执行 GS(GS Instancing,每面 1 个实例):\n";
    int totalEmitted = 0;
    for (int face = 0; face < 6; face++) {
        auto output = cubemapGS(cubeTri, face);
        totalEmitted += (int)output.size();
    }
    std::cout << "共发射 " << totalEmitted << " 个顶点(分配到不同 RTAI)\n";
    std::cout << "\n=== 演示结束 ===\n";
    return 0;
}

编译运行命令:

g++ -std=c++17 -O2 -o gs_demo gs_demo.cpp && ./gs_demo

十一、各着色器阶段对比


特性VSGSPS
输入粒度1 个顶点1 个图元(1~32 顶点)1 个 Quad(4 像素)
输出粒度1 个顶点(固定)0~N 个顶点(可变)1~8 个颜色向量(固定)
批次大小控制容易(贪心打包)困难(依赖顶点缓存命中率)容易(光栅化器输出稳定)
需要第二次 PA否是(Strip 重建)否
需要重排序是(图元 ID)是(且还要扫描边界)是(图元 ID)
典型利用率高低~中中~高
Pass-Through 开销零3x~7x 减速零

十二、GS 的实际适用场景

GS 性能有代价,只在合适的场景使用才值得:
适合 GS 的场景:

  • Point Sprite 展开:把一个点扩展成一个朝向相机的 Quad(4 个顶点)。每个点固定输出 4 顶点,批次大,利用率还可以
  • 立方体贴图一次 Pass:配合 RTAI + GS Instancing(instance=6),一次 Draw Call 渲染 6 个面
  • 层叠阴影贴图(CSM)一次 Pass:配合 VPAI,一次渲染多个级联
  • 几何体剔除:GS 输出 0 个顶点 = 剔除该图元(比 CPU 剔除更灵活)
    不适合 GS 的场景:
  • 纯透传(什么都不做)
  • 大量顶点扩展(细分请用 Tessellation Shader)
  • 需要在图元间通信(GS 调用间相互独立)

十三、通俗比喻

GS 的低利用率问题 = 工厂的生产线调度:

VS 就像螺丝钉生产线:一颗进,一颗出,流水线满负荷。GS 就像手工组装:师傅要先把几个零件(顶点)凑在一起(图元),然后根据图纸(着色器代码)决定要做出 0~N 个零件。有时候材料没凑齐(批次小),有时候一下子做出很多(输出多),很难让所有工位同时满负荷。
GS Instancing = 把一个师傅拆成多个实习生:
原来一个师傅要依次检查 6 个方向。现在招 6 个实习生,每人只负责 1 个方向,同时开工。6 倍的工作量,6 倍的并行度,整体利用率大幅提升。
RTAI = 快递分拣机器人贴目的地标签:
GS 就像分拣机器人,把进来的包裹(图元)复制成多份,每份贴上不同的目的地标签(RTAI),然后分别送往不同的目的地(纹理数组不同层)。整个过程在一次送货(Draw Call)中完成。
下一篇将介绍:Stream-Out(流输出)——GS 的另一个输出目的地,把几何数据写回内存。

图形管线之旅 2011 第十一章:Stream-Out(流输出)详解

原文:A trip through the Graphics Pipeline 2011, part 11
中文详解版,面向从零开始的读者

一、什么是 Stream-Out(SO)?

在正常的渲染流程中,顶点数据经过各个着色器处理后,最终会进入光栅化、像素着色等阶段,最终写入屏幕(帧缓冲区)。
但有时候我们不想把数据画到屏幕上,而是想把某个着色阶段的输出结果保存到一块普通的内存缓冲区里,以便后续复用。这就是 Stream-Out(流输出) 的用途。
用一个比喻来说:

正常流程:
顶点着色器 --> 几何着色器 --> 光栅化 --> 像素着色器 --> 屏幕
Stream-Out:
顶点着色器 --> 几何着色器 --> [拦截!保存到内存缓冲区] --> (可选)继续往下走

SO 的典型用途

  • 缓存蒙皮顶点数据(Skinned Vertex Cache):
    角色动画中,每帧都要对骨骼绑定的顶点做大量矩阵变换(蒙皮)。如果同一个姿势要渲染多次(例如阴影 pass + 主渲染 pass),可以用 SO 把蒙皮结果保存下来,第二次直接读取,避免重复计算。
  • 穷人版 Compute Shader:
    D3D10 时代还没有计算着色器,可以用 SO 模拟一些通用计算任务。D3D11 之后可以直接用 CS 4.0。

二、顶点着色器 Stream-Out(NULL GS 模式)

2.1 这是什么?

这是 SO 最简单的形式:只有顶点着色器(VS),没有真正的几何着色器(GS),但仍然走 SO 路径。
文档里对此说明很少,但实现方式其实很简单:

把顶点着色器字节码(VS bytecode)传给
CreateGeometryShaderWithStreamOutput()
得到一个"假的 GS 对象",再通过 GSSetShader() 绑定上去

这个"假的 GS 对象"本质上是一个空 GS(NULL GS):它不做任何几何处理,只是一个"适配器",让 VS 的输出能被 SO 系统接收。
下面是整个流程图:

可选

输入装配
Input Assembler

顶点着色器
Vertex Shader

图元装配
Primitive Assembly

Stream-Out
写入内存缓冲区

光栅化/裁剪等后续阶段

2.2 SO 声明(Declaration)与性能

SO 在创建时需要指定一个输出声明(SO Declaration),告诉硬件:把着色器输出的哪些属性,写到 SO 目标缓冲区的哪个位置。
有两种情况:

情况说明性能
SO 声明与 VS 输出声明完全一致数据可以直接"流"入缓冲区,几乎无额外开销最优
SO 声明与 VS 输出不一致(跳过某些属性,或顺序不同)需要额外的重排序(gather 操作),或产生大量小写入较慢

结论:让 SO 声明和 VS/GS 输出声明保持一致,性能最好。

2.3 SO 的带宽限制

SO 访问内存的路径通常不如 ROP(光栅输出单元)那样高效,往往只能访问一个内存通道。
此外,SO 输出的数据始终是 32 位全精度浮点数(full float),没有办法像顶点缓冲区那样使用压缩格式来节省带宽。这两点叠加起来,意味着用 SO 产生大量数据时要格外注意带宽消耗。

2.4 SO 操作的是"图元",不是"顶点"

这是一个非常重要的细节!SO 接收的是已经装配好的图元(Primitives),不是原始的单个顶点。
图元装配阶段会把相邻图元信息(Adjacency)丢弃,所以相邻顶点的数据不会进入 SO 缓冲区。
这个特性对实例化蒙皮网格有重要影响,见下面的分析。

错误做法 vs. 正确做法

错误做法(导致数据膨胀):

原始三角形网格(索引缓冲区 + 顶点缓冲区)
  --> 正常绘制(三角形列表)
  --> SO 输出
结果:每个三角形有 3 个顶点,共享的顶点被展开!
例如:10000 个三角形 --> 30000 个未共享顶点写入 SO

正确做法(1:1 对应,无数据膨胀):

第一遍(蒙皮 pass):
  把原始网格以"点列表(Point List)"方式绘制
  --> 每个顶点只被着色器处理一次
  --> SO 输出:蒙皮后的顶点缓冲区(与原始顶点缓冲区 1:1 对应)
第二遍(渲染 pass):
  用第一遍产生的顶点缓冲区 + 原始索引缓冲区
  --> 正常绘制(三角形列表)
  --> 可以做多次实例化,完全复用蒙皮结果

用 ASCII 图示意:

原始数据:
  顶点缓冲区: [V0, V1, V2, V3, V4, ...]
  索引缓冲区: [0,1,2, 0,2,3, 1,3,4, ...]
第一遍(点列表 + SO):
  输入:  V0  V1  V2  V3  V4  ...
  输出:  S0  S1  S2  S3  S4  ...   (S = Skinned,蒙皮后)
第二遍(用蒙皮后的顶点 + 原始索引):
  三角形: S0-S1-S2,  S0-S2-S3,  S1-S3-S4, ...
  --> 画到屏幕上,或再次 SO

三、几何着色器 SO:多流(Multiple Streams)

3.1 GS 引入的新能力

当真正使用几何着色器(GS)时,SO 拥有了更多灵活性:多个输出流(Streams)。
这是 D3D11 新增的功能,D3D10 级别硬件不支持。
每个 GS 最多可以写到 4 个输出流,每个流可以独立路由:

输出流能做什么
Stream 0, 1, 2, 3最多 4 个
每个 Stream → SO 目标一个 Stream 可以写到多个 SO 目标(一对多)
单个 SO 目标只能接收一个 Stream 的数据(不能多个 Stream 合并写一个目标)
其中最多一个 Stream可以继续往下走渲染管线(光栅化等)

流程如下:

发往光栅化

几何着色器 GS

Stream 0

Stream 1

Stream 2

Stream 3

SO 目标缓冲区 0

SO 目标缓冲区 1

SO 目标缓冲区 2

光栅化阶段

说明:一个 Stream 可以同时送往 SO 缓冲区和光栅化阶段(两者可以同时进行),但只允许最多一个 Stream 进入光栅化。

3.2 GS SO 同样操作图元

和 NULL GS 的 SO 一样,GS 输出的图元带(strips)在进入 SO 之前会被展开成完整的线段或三角形。

四、追踪输出大小:DrawAuto 的设计思想

4.1 问题:我们不知道写了多少数据

这是 SO 面临的一个核心挑战:

  • GS 中每次调用可能产生可变数量的图元
  • 即使是简单的 VS SO,如果顶点索引缓冲区里有"图元切割(Primitive Cut)"标志,实际产生的图元数也会变化
    所以,我们事先不知道 SO 缓冲区里实际写入了多少个顶点。
    但如果之后要用这个 SO 缓冲区来绘制,就必须知道顶点数!

4.2 天真的方案:查询(Query)机制

一种直觉上的解法是:绘制完成后,通过 Query 读回"写入了多少顶点"这个数字,然后再用这个数字发起下一次 Draw Call。
但这会造成严重的性能问题,流程如下:

GPU 写入 SO 缓冲区
  --> GPU 生成"写入数量"计数器(32位整数)
  --> 通过总线传回 CPU
  --> CPU 拿到数据
  --> CPU 发起新的 Draw Call
  --> 命令通过总线传给 GPU
  --> GPU 执行绘制

这整个过程造成了一次 GPU → CPU → GPU 的往返(Round-Trip),代价极高,会打断 GPU 的流水线。

4.3 优雅的方案:DrawAuto

核心思想:GPU 自己已经知道写了多少!让 GPU 自己用那个计数器来画,不告诉 CPU。
SO 单元在写入数据的同时,会维护一个内部计数器,记录当前写到了哪个位置。这个计数器同时保存在内存中(因为 SO 可能分多个 pass 写同一个缓冲区)。
调用 DrawAuto() 时,GPU 直接读取这个内部计数器,用它代替"顶点数量"参数,完全不需要 CPU 介入。
对比如下:

普通 Draw Call:
  CPU 告诉 GPU "画 N 个顶点"
  N 必须由 CPU 知道
DrawAuto:
  GPU 自己知道 SO 写了多少顶点
  直接用那个计数器来画
  CPU 不需要参与

第二遍:DrawAuto 绘制

第一遍:SO 写入

写数据

维护

直接读取

作为顶点输入

顶点着色器

SO 单元

SO 缓冲区

写入计数器
存在显存中

DrawAuto

渲染管线

注意:Query 机制依然存在(用于调试、检测是否溢出等),但它不在关键渲染路径上,不影响性能。

五、完整概念总结

SO 核心知识点速查


知识点说明
SO 是什么把着色器输出保存到内存缓冲区,而非继续走渲染管线
NULL GS SO把 VS 字节码传给 CreateGeometryShaderWithStreamOutput
SO 声明要一致保证声明与输出声明相同,避免重排序开销
SO 带宽有限只有一个内存通道,且只支持全精度浮点
SO 操作图元输入是装配好的图元,不是单个顶点
点列表技巧蒙皮 pass 用点列表,避免顶点重复展开
多流(D3D11)GS 最多 4 个流,一对多写 SO 目标,最多一个流进光栅化
DrawAutoGPU 自持计数器,避免 GPU→CPU→GPU 往返

整体数据流(完整管线视角)

输入装配 (IA)
    |
    v
顶点着色器 (VS)
    |
    v
[NULL GS 模式] 或 [真实 GS,最多4个流]
    |
    +----> Stream-Out (SO) 单元
    |           |
    |           +---> SO 目标缓冲区 0
    |           +---> SO 目标缓冲区 1
    |           +---> SO 目标缓冲区 2
    |           +---> SO 目标缓冲区 3
    |           |
    |           +---> 内部写入计数器 (供 DrawAuto 使用)
    |
    +----> (最多一个流) 继续走渲染管线
                |
                v
           视口/裁剪/光栅化
                |
                v
           像素着色器
                |
                v
              ROP
                |
                v
             帧缓冲区(屏幕)

六、C++ 代码示例(附详细注释)

下面用 D3D11 风格的伪代码演示 VS Stream-Out 的典型用法(蒙皮顶点缓存场景):

// ============================================================
// 头文件(不使用万能头)
// ============================================================
#include <d3d11.h>       // D3D11 核心 API
#include <d3dcompiler.h> // 着色器编译
#include <cstdio>        // printf
// ============================================================
// 演示:如何创建一个用于 VS Stream-Out 的"假 GS 对象"
// 并使用 DrawAuto 从 SO 缓冲区绘制
// ============================================================
// 假设以下变量已由引擎初始化:
// ID3D11Device*        g_pDevice;
// ID3D11DeviceContext* g_pContext;
// --------------------------------
// Step 1: 编译顶点着色器
// --------------------------------
// 这里省略实际文件读取,假设 vsBlob 已包含编译好的 VS 字节码
ID3DBlob* vsBlob = nullptr;
// D3DCompileFromFile(L"SkinVS.hlsl", nullptr, nullptr,
//     "main", "vs_5_0", 0, 0, &vsBlob, nullptr);
// --------------------------------
// Step 2: 定义 SO 输出声明(SO Declaration)
// 这告诉硬件:把哪些语义写到哪个 SO 目标槽
// --------------------------------
D3D11_SO_DECLARATION_ENTRY soDecl[] = {
    // { Stream索引, 语义名, 语义索引, 起始分量, 分量数, 输出槽 }
    { 0, "POSITION", 0, 0, 3, 0 }, // 输出 POSITION 的 xyz 三个分量到槽 0
    { 0, "NORMAL",   0, 0, 3, 0 }, // 输出 NORMAL 的 xyz 三个分量到槽 0
    { 0, "TEXCOORD", 0, 0, 2, 0 }, // 输出 TEXCOORD 的 uv 两个分量到槽 0
    // 合计:每个顶点 = (3 + 3 + 2) * 4 字节 = 32 字节
};
UINT numEntries = ARRAYSIZE(soDecl);
// 每个 SO 目标槽的步长(stride),单位字节
// 槽 0 的步长 = 32 字节(匹配上面的声明)
UINT bufferStrides[] = { 32 };
// --------------------------------
// Step 3: 用 VS 字节码创建"假 GS 对象"(这是关键!)
// 注意:传入的是 VS 字节码,而不是 GS 字节码
// 这个函数名字叫 CreateGeometryShaderWithStreamOutput
// 但我们故意传 VS bytecode,得到 NULL GS + SO 的效果
// --------------------------------
ID3D11GeometryShader* pSoGS = nullptr;
// g_pDevice->CreateGeometryShaderWithStreamOutput(
//     vsBlob->GetBufferPointer(), // VS 字节码(不是 GS!)
//     vsBlob->GetBufferSize(),    // VS 字节码大小
//     soDecl,                     // SO 输出声明
//     numEntries,                 // 声明条目数
//     bufferStrides,              // 每个输出槽的步长
//     1,                          // 输出槽数量
//     D3D11_SO_NO_RASTERIZED_STREAM, // 不发往光栅化(纯 SO 模式)
//     nullptr,                    // 类链接(高级功能,通常为 null)
//     &pSoGS                      // 输出的"假 GS 对象"
// );
// --------------------------------
// Step 4: 创建 SO 目标缓冲区
// 这个缓冲区用来接收 SO 的输出数据
// --------------------------------
D3D11_BUFFER_DESC soBufferDesc = {};
soBufferDesc.ByteWidth      = 32 * 10000; // 最多存 10000 个顶点,每个 32 字节
soBufferDesc.Usage          = D3D11_USAGE_DEFAULT;
soBufferDesc.BindFlags      = D3D11_BIND_STREAM_OUTPUT // 可以作为 SO 目标
                            | D3D11_BIND_VERTEX_BUFFER; // 同时可以作为顶点缓冲区
soBufferDesc.CPUAccessFlags = 0;
soBufferDesc.MiscFlags      = 0;
ID3D11Buffer* pSoBuffer = nullptr;
// g_pDevice->CreateBuffer(&soBufferDesc, nullptr, &pSoBuffer);
// --------------------------------
// Step 5: 第一遍渲染(蒙皮 pass)
// 用"点列表"绘制,每个顶点只处理一次,写入 SO 缓冲区
// --------------------------------
// 绑定 SO 目标缓冲区
UINT offset = 0;
// g_pContext->SOSetTargets(1, &pSoBuffer, &offset);
// 绑定"假 GS"(含 SO 逻辑的 NULL GS)
// g_pContext->GSSetShader(pSoGS, nullptr, 0);
// 设置图元拓扑为"点列表"
// g_pContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_POINTLIST);
// 绘制原始顶点(非索引绘制)
// 每个顶点执行一次 VS(蒙皮变换),结果写入 SO 缓冲区
// g_pContext->Draw(numVertices, 0);
// 解除 SO 目标绑定(避免后续 pass 误写)
// ID3D11Buffer* pNullBuffer = nullptr;
// g_pContext->SOSetTargets(1, &pNullBuffer, &offset);
// --------------------------------
// Step 6: 第二遍渲染(实际渲染 pass)
// 用第一遍产生的蒙皮顶点 + 原始索引缓冲区渲染
// --------------------------------
// 取消 GS 绑定(回到普通渲染模式)
// g_pContext->GSSetShader(nullptr, nullptr, 0);
// 把 SO 缓冲区作为顶点缓冲区绑定
// UINT stride = 32, vbOffset = 0;
// g_pContext->IASetVertexBuffers(0, 1, &pSoBuffer, &stride, &vbOffset);
// 绑定原始索引缓冲区
// g_pContext->IASetIndexBuffer(pIndexBuffer, DXGI_FORMAT_R16_UINT, 0);
// 设置回三角形列表
// g_pContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// DrawAuto:GPU 自己知道 SO 写了多少顶点,直接用那个计数
// 不需要 CPU 传入顶点数量,避免 GPU->CPU->GPU 往返
// g_pContext->DrawAuto();
printf("Stream-Out 演示流程完成\n");

七、数学背景(可选阅读)

蒙皮变换(Skinning)简介

蒙皮(Skinning)是让角色网格随骨骼运动的技术。每个顶点受多根骨骼的影响,最终位置是多个骨骼变换的加权和:
pskinned=∑i=0n−1wi⋅Mi⋅pbind \mathbf{p}_{skinned} = \sum_{i=0}^{n-1} w_i \cdot \mathbf{M}_i \cdot \mathbf{p}_{bind} pskinned​=i=0∑n−1​wi​⋅Mi​⋅pbind​
其中:

  • pbind\mathbf{p}_{bind}pbind​ 是顶点在绑定姿态(T-Pose)下的位置
  • Mi\mathbf{M}_iMi​ 是第 iii 根骨骼的变换矩阵(从绑定空间到当前动画空间)
  • wiw_iwi​ 是该骨骼对该顶点的权重,满足 ∑iwi=1\sum_{i} w_i = 1∑i​wi​=1
  • nnn 通常取 4(每顶点最多 4 根骨骼影响)
    如果同一帧需要用同一姿势绘制多次(如主渲染 + 阴影),每次绘制都重新算一遍蒙皮代价很高。用 SO 把 pskinned\mathbf{p}_{skinned}pskinned​ 缓存下来,后续 pass 直接用,节省了 O(n×V)O(n \times V)O(n×V) 的矩阵乘法(VVV 为顶点数)。

八、关键词速查表


术语全称/含义
SOStream-Out,流输出
GSGeometry Shader,几何着色器
VSVertex Shader,顶点着色器
NULL GS没有真正 GS 逻辑的"假 GS 对象"
SO TargetSO 目标缓冲区,接收 SO 写入的内存
SO DeclarationSO 声明,指定哪些属性写到哪个槽
DrawAuto用 GPU 内部计数器代替 CPU 传入顶点数的绘制命令
Primitive Assembly图元装配,把顶点组合成三角形/线段等
Skinning蒙皮,用骨骼矩阵变换顶点
Round-TripGPU→CPU→GPU 往返,代价极高的操作
Stride步长,每个顶点在缓冲区中占多少字节

图形管线之旅 2011 第十二章:曲面细分(Tessellation)详解

原文:A trip through the Graphics Pipeline 2011, part 12
中文详解版,面向从零开始的读者

一、什么是曲面细分(Tessellation)?

1.1 一句话解释

曲面细分就是把一个粗糙的、顶点数少的几何图形,自动细分成很多细小三角形,从而得到更平滑、更细腻的表面。
想象你有一个用很少顶点描述的球体(看起来像个多面体),曲面细分会在 GPU 上自动把它"磨光",生成成千上万个小三角形,让它真正圆润起来。

细分前(粗糙,少三角形):      细分后(光滑,多三角形):
      *                               *
     /|\                           / | \
    / | \                         *--*--*
   /  |  \                       /|\ | /|\
  *---+---*                     * | \|/ | *
                                  *--*--*

1.2 为什么要在 GPU 上做?

细分是 D3D11 / Shader 5.x 时代引入的重要固定功能管线组件,是多年以来图形管线中第一个新增的非可编程固定功能单元。
传统做法是在 CPU 上预先生成大量细分网格,但这样:

  • 内存占用大(要存所有细分结果)
  • 无法根据距离动态调整精度(远处的物体细分那么细根本浪费)
    GPU 曲面细分的优势:
  • 按需细分:近处多细分,远处少细分,动态调整
  • 节省内存:只需存储少量控制点(Patch),细分结果在 GPU 实时生成
  • 无缝过渡:相邻 Patch 之间可以精确接缝,不产生裂缝

二、曲面细分的三个新阶段

D3D11 的曲面细分管线由三个部分组成,插入在顶点着色器和几何着色器之间:

顶点着色器
Vertex Shader

壳着色器
Hull Shader

固定功能细分单元
Tessellator Unit
固定硬件

域着色器
Domain Shader

几何着色器
可选

光栅化

阶段D3D11 叫法OpenGL 叫法是否可编程
壳着色器Hull Shader (HS)Tessellation Control Shader (TCS)是
细分单元TessellatorTessellator否(固定硬件)
域着色器Domain Shader (DS)Tessellation Evaluation Shader (TES)是

三、Patch(面片):细分的基本单元

3.1 什么是 Patch?

Patch 是曲面细分的输入单位。它是一组控制点(Control Points),描述一块曲面的"骨架"。固定功能细分单元根据这些控制点,在某个参数域(Domain)内生成细分后的顶点和连接关系。
细分单元只关心
拓扑结构
(顶点数量和连接方式),不关心如何从控制点计算出最终位置(那是 Domain Shader 的工作)。

3.2 两种 Patch 类型

从细分单元的视角看,只有两种本质不同的 Patch 类型:

四边形 Patch(Quad Domain)

参数域是一个 [0,1]×[0,1][0,1] \times [0,1][0,1]×[0,1] 的单位正方形,用两个参数 u,vu, vu,v 描述。

v=1  *-----------*
     |           |
     |  Quad     |
     |  Domain   |
     |           |
v=0  *-----------*
    u=0          u=1

通常由两个一维基函数的张量积(Tensor Product)构造,例如 B 样条面片、贝塞尔面片。

三角形 Patch(Tri Domain)

参数域是一个三角形,用重心坐标(Barycentric Coordinates) (u,v,w)(u, v, w)(u,v,w) 描述,满足:
u+v+w=1,u,v,w≥0u + v + w = 1, \quad u,v,w \geq 0u+v+w=1,u,v,w≥0
因为三个坐标中有一个可以由另外两个推导出来(w=1−u−vw = 1 - u - vw=1−u−v),所以实际上只有两个自由度,细分单元不需要传入 www,可以在 Domain Shader 里用上式计算。

         * (0,1,0)
        / \
       /   \
      / Tri \
     / Domain\
    *---------*
(1,0,0)     (0,0,1)

还有一种等值线(Isoline)类型,产生的是曲线而非曲面,本文不展开。

四、细分因子(Tessellation Factor, TF)

4.1 TF 是什么?

细分因子控制"细分多少",是一个数值,决定每条边被切成几段。
每个 Patch 有多个 TF:

  • 边缘 TF:控制每条边切成几段(四边形有 4 条边,三角形有 3 条边)
  • 内部 TF:控制 Patch 内部的密度(四边形有 u 和 v 两个内部 TF,三角形有一个)
    关键规则:如果两个相邻 Patch 共享一条边,它们对这条边必须使用完全相同的 TF,否则细分结果无法对齐,会产生裂缝(cracks)。GPU 不会自动帮你检查,这完全由程序员保证。

4.2 整数细分(Integer Partitioning)

最简单的模式。TF 如果是小数,直接向上取整到下一个整数。
以三角形为例,TF = N 时:

  • N 为奇数:产生 N+12\dfrac{N+1}{2}2N+1​ 个同心环,最内部是一个三角形
  • N 为偶数:产生 N2\dfrac{N}{2}2N​ 个同心环,中心是一个顶点
    例如 TF = 3(奇数)的三角形细分示意(2 个同心环,最内是三角形):
       *
      /|\
     / | \
    *--*--*       <- 外环(受边缘 TF 控制)
   /|\ | /|\
  * | \|/ | *
    *--*--*       <- 内环
       *          <- 最内:中心三角形的顶点

TF = 2(偶数)最简单的情况,中心是一个顶点:

       *
      /|\
     / | \
    *  *  *
     \ | /
      \|/
       *          <- 中心顶点

4.3 分数细分(Fractional Tessellation)

整数细分的问题:当 TF 从 3 跳到 4 时,网格结构突变,视觉上会产生**“弹出”(pop)** 的跳变感。
分数细分的解法:新增的顶点从已有顶点的位置开始,随 TF 增大渐渐移动到最终位置,实现平滑过渡。
分数奇数(Fractional-Odd)细分举例:

TF = 3.0:  结构 == 整数 TF 3(2 个环)
TF = 3.001: 拓扑上等价于 TF 5(3 个环),
            但中间那个环极度收缩,几乎退化为一点
TF = 4.999: 3 个环,中间那环接近 TF 5 时的正常位置
TF = 5.0:   结构 == 整数 TF 5(3 个环)

这样视觉上就是顶点位置渐渐变化,而非拓扑突变。

分区类型说明适用场景
Integer向上取整为整数简单,有弹出感
Pow2向上取整为 2 的幂特殊需求
Fractional-Odd奇数间平滑过渡平滑动画,三角形 Patch
Fractional-Even偶数间平滑过渡平滑动画,四边形 Patch

五、四边形的对角线选择

细分四边形时,每个小格需要划分成两个三角形,有两种对角线方向可选。
规则是:对角线指向远离 Patch 中心的方向,并使用一致的平局处理规则。这样做是为了让细分结果具有最大的旋转对称性。

中心 (0.5, 0.5)
  *---*---*
  |  /|  /|
  | / | / |   <- 对角线指向右上(远离中心方向)
  |/  |/  |
  *---*---*
  |  /|  /|
  | / | / |
  |/  |/  |
  *---*---*

六、完整管线流程详解

6.1 整体数据流

壳着色器 Hull Shader

TF <= 0 或 NaN

TF 合法

域着色器 Domain Shader

每个细分顶点执行一次

输入: u,v 坐标 + 控制点 + Patch 常量

输出: 顶点最终位置/法线/UV 等

固定功能细分单元 Tessellator

读取 TF

生成域内顶点位置
u,v 坐标

生成连接关系
索引缓冲区

输入控制点
Input Control Points

计算输出控制点
Output Control Points

计算 Patch 常量
Patch Constants

计算细分因子 TF

该 Patch 被剔除
丢弃

图元装配

几何着色器
可选

视口变换/裁剪/光栅化

6.2 各阶段职责对比

控制点 (少量顶点)
      |
      v
[壳着色器 Hull Shader]
  - 接收: 一整个 Patch 的控制点
  - 输出: 新的控制点 + Patch 常量 + 细分因子 TF
  - 注意: 每个 Patch 只运行一次(不像 VS 每顶点一次)
      | TF 如果 <= 0 或 NaN --> 整个 Patch 被丢弃(廉价的剔除!)
      |
      v
[固定功能细分单元 Tessellator]
  - 接收: 少量 TF 数值
  - 输出: 大量 u,v 域坐标 + 索引缓冲区
  - 本质: 一个状态机,输入极少,输出极紧凑
  - 不做矩阵运算,不做纹理采样
      |
      v
[域着色器 Domain Shader]
  - 接收: u,v 坐标(每个细分顶点不同)+ 控制点 + Patch 常量(全部相同)
  - 输出: 该顶点的最终 3D 位置、法线、UV 等
  - 类似顶点着色器,但 u,v 是输入参数

七、壳着色器(Hull Shader)深入

7.1 HS 的特殊之处:多阶段并行

HS 在 D3D11 中被编译成多个阶段(Phases),每个阶段可以包含多个独立线程,充分暴露并行性。这是微软为了避免几何着色器(GS)的性能陷阱而精心设计的。

7.2 HS 的输出量是固定的!

这是 HS 相比 GS 的最大优势之一:

对比项几何着色器 GS壳着色器 HS
输出量可变(每次调用可输出不同数量顶点)固定(编译时确定)
缓冲管理复杂,需要运行时动态管理简单,预分配
内存效率低(需要预留最坏情况空间)高

因为输出量固定,16 个 Hull 并行执行时,GPU 在开始执行之前就能计算出每个 Hull 的数据会写到哪个内存位置,无需运行时调度。

7.3 廉价的 Patch 剔除

如果 HS 输出的任一 TF 满足下列条件之一,整个 Patch 会被静默丢弃(silently culled):

  • TF ≤0\leq 0≤0
  • TF 为 NaN(非法浮点数)
    这是一种极其廉价的剔除手段:HS 运行开销本来就小(每个 Patch 只运行一次),而丢弃一个 Patch 可以避免运行大量的 DS(因为细分后三角形数量远多于 Patch 数量)。
    典型用途:在 HS 里做背面剔除或视锥体剔除,对完全不可见的 Patch 输出 TF = 0。

八、域着色器(Domain Shader)深入

8.1 DS 为什么高效?

Domain Shader 的输入结构非常规整:

  • 变化的部分:每个细分顶点的域坐标 (u,v)(u, v)(u,v)(或三角形的 (u,v,w)(u, v, w)(u,v,w))
  • 不变的部分:整个 Patch 的控制点、Patch 常量、常量缓冲区
    这和顶点着色器非常像,数据流非常简单,GPU 可以高效并行执行。

8.2 三角形域中 w 不需要传入

由于:
w=1−u−vw = 1 - u - vw=1−u−v
细分单元只需要传入 uuu 和 vvv,域着色器里可以直接计算 www,无需额外存储和传输。

8.3 为什么 DS 比 GS 好?

GS 做三角形放大(Triangle Amplification)时,浪费大量 ALU 周期处理控制流和缓冲区管理,而且必须预留最坏情况的输出缓冲区。
DS 的放大发生在固定功能细分单元里:

  • 输入极少(几个 TF)
  • 输出极紧凑(本质是索引缓冲区 + 2D 坐标)
  • DS 只做"真正的着色工作",不做任何管理开销

九、与几何着色器的配合

细分管线的输出可以继续进入 GS,但有一个重要限制:
细分单元不会为 GS 生成邻接(Adjacency)信息。
原因:

  • Patch 内部的邻接信息理论上可以生成(只需更多索引)
  • 但 Patch 边缘的跨 Patch 邻接信息需要"全局网格感知"(即知道相邻 Patch 的内容)
  • 而细分管线的整个设计哲学就是让每个 Patch 完全独立处理,不依赖其他 Patch
  • 引入跨 Patch 邻接就会破坏这个设计,代价极高
    因此,细分输出到 GS 时,只有普通三角形,没有邻接信息。

十、精度与对称性保证

D3D11 规范对细分单元有严格的精度要求:

  • 所有供应商(NVIDIA、AMD 等)对同样输入必须产生完全相同(bit-exact)的域坐标
  • 细分单元写出的所有域坐标,uuu 和 1−u1-u1−u(以及 vvv 和 1−v1-v1−v)必须都能精确表示为 float
  • 这个约束保证了:共享边 ABABAB 对于两个相邻 Patch,一个看成 ABABAB,另一个看成 BABABA,细分结果必须完全一致,从而 DS 有能力产生无裂缝网格
    唯一没有规定的是:顶点和三角形的生成顺序(可以任意顺序,只要相同输入总得到相同输出)。

十一、C++ 代码示例(D3D11 曲面细分完整框架)

// ============================================================
// 头文件(不使用万能头)
// ============================================================
#include <d3d11.h>
#include <d3dcompiler.h>
#include <DirectXMath.h>  // DirectX 数学库
#include <cstdio>
#include <cstring>
// ============================================================
// 演示:D3D11 曲面细分管线设置
// 场景:对一个四边形 Patch(Quad Patch)进行贝塞尔曲面细分
// ============================================================
using namespace DirectX;
// --------------------------------
// 顶点结构:Patch 的控制点
// 一个三次贝塞尔四边形 Patch 有 4x4 = 16 个控制点
// --------------------------------
struct ControlPoint {
    XMFLOAT3 position; // 控制点位置
};
// --------------------------------
// 常量缓冲区:每帧更新的矩阵数据
// --------------------------------
struct PerFrameCB {
    XMMATRIX worldViewProj; // 世界-视图-投影矩阵
    float    tessellationFactor; // 基础细分因子(传给 Hull Shader)
    float    padding[3];         // 填充到 16 字节对齐
};
// ============================================================
// 伪代码演示(真实项目中由引擎初始化 D3D11 设备)
// 以下代码展示曲面细分管线的设置逻辑
// ============================================================
// --------------------------------
// Step 1: 创建包含 16 个控制点的顶点缓冲区(4x4 贝塞尔面片)
// --------------------------------
void CreatePatchBuffer(ID3D11Device* pDevice, ID3D11Buffer** ppVB)
{
    // 4x4 控制点网格,描述一个轻微弯曲的曲面
    // 中间四个控制点适当抬高,产生"鼓包"效果
    ControlPoint controlPoints[16] = {
        // 第一行 (v=0)
        {{ -1.5f, 0.0f, -1.5f }},
        {{ -0.5f, 0.0f, -1.5f }},
        {{  0.5f, 0.0f, -1.5f }},
        {{  1.5f, 0.0f, -1.5f }},
        // 第二行 (v=1/3)
        {{ -1.5f, 0.0f, -0.5f }},
        {{ -0.5f, 1.5f, -0.5f }}, // 抬高!产生凸起
        {{  0.5f, 1.5f, -0.5f }}, // 抬高!
        {{  1.5f, 0.0f, -0.5f }},
        // 第三行 (v=2/3)
        {{ -1.5f, 0.0f,  0.5f }},
        {{ -0.5f, 1.5f,  0.5f }}, // 抬高!
        {{  0.5f, 1.5f,  0.5f }}, // 抬高!
        {{  1.5f, 0.0f,  0.5f }},
        // 第四行 (v=1)
        {{ -1.5f, 0.0f,  1.5f }},
        {{ -0.5f, 0.0f,  1.5f }},
        {{  0.5f, 0.0f,  1.5f }},
        {{  1.5f, 0.0f,  1.5f }},
    };
    D3D11_BUFFER_DESC vbDesc = {};
    vbDesc.ByteWidth      = sizeof(controlPoints);
    vbDesc.Usage          = D3D11_USAGE_IMMUTABLE; // 不需要 CPU 修改
    vbDesc.BindFlags      = D3D11_BIND_VERTEX_BUFFER;
    vbDesc.CPUAccessFlags = 0;
    D3D11_SUBRESOURCE_DATA initData = {};
    initData.pSysMem = controlPoints;
    pDevice->CreateBuffer(&vbDesc, &initData, ppVB);
}
// --------------------------------
// Step 2: 设置 Input Layout
// 曲面细分和普通渲染一样需要 Input Layout
// --------------------------------
void CreateInputLayout(ID3D11Device* pDevice,
                       ID3DBlob* pVSBlob,
                       ID3D11InputLayout** ppLayout)
{
    D3D11_INPUT_ELEMENT_DESC inputDesc[] = {
        // 语义名, 索引, 格式, 输入槽, 字节偏移, 数据类, 实例步长
        { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
          D3D11_INPUT_PER_VERTEX_DATA, 0 },
    };
    pDevice->CreateInputLayout(
        inputDesc,
        ARRAYSIZE(inputDesc),
        pVSBlob->GetBufferPointer(),
        pVSBlob->GetBufferSize(),
        ppLayout
    );
}
// --------------------------------
// Step 3: 渲染函数
// 展示曲面细分管线的完整绑定流程
// --------------------------------
void RenderTessellatedPatch(
    ID3D11DeviceContext* pCtx,
    ID3D11Buffer*        pVB,
    ID3D11VertexShader*  pVS,   // 顶点着色器(简单透传控制点)
    ID3D11HullShader*    pHS,   // 壳着色器(计算 TF,输出控制点)
    ID3D11DomainShader*  pDS,   // 域着色器(由 u,v 计算最终位置)
    ID3D11PixelShader*   pPS,   // 像素着色器(着色)
    ID3D11Buffer*        pCB,   // 常量缓冲区
    float tessellationFactor    // 细分因子(1~64)
)
{
    // --- 更新常量缓冲区 ---
    // (此处省略 Map/Unmap,实际项目中需要更新 worldViewProj 和 TF)
    // --- 绑定顶点缓冲区 ---
    UINT stride = sizeof(ControlPoint); // 每个控制点 12 字节(float3)
    UINT offset = 0;
    pCtx->IASetVertexBuffers(0, 1, &pVB, &stride, &offset);
    // --- 关键!设置图元拓扑为 "16 控制点的 Patch" ---
    // D3D11_PRIMITIVE_TOPOLOGY_16_CONTROL_POINT_PATCHLIST
    // 表示每 16 个顶点组成一个 Patch(对应 4x4 贝塞尔曲面)
    // 其他常见值:
    //   D3D11_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST  -> 三角形 Patch
    //   D3D11_PRIMITIVE_TOPOLOGY_4_CONTROL_POINT_PATCHLIST  -> 简单四边形 Patch
    pCtx->IASetPrimitiveTopology(
        D3D11_PRIMITIVE_TOPOLOGY_16_CONTROL_POINT_PATCHLIST
    );
    // --- 绑定着色器 ---
    pCtx->VSSetShader(pVS, nullptr, 0); // VS:透传控制点位置
    pCtx->HSSetShader(pHS, nullptr, 0); // HS:计算 TF 和输出控制点
    pCtx->DSSetShader(pDS, nullptr, 0); // DS:由域坐标求最终顶点位置
    pCtx->PSSetShader(pPS, nullptr, 0); // PS:像素着色
    // --- 绑定常量缓冲区到 HS 和 DS ---
    pCtx->HSSetConstantBuffers(0, 1, &pCB); // HS 需要读 TF
    pCtx->DSSetConstantBuffers(0, 1, &pCB); // DS 需要读变换矩阵
    // --- 发起绘制 ---
    // 16 个控制点 = 1 个 Patch
    // Draw(16, 0) 意味着绘制 1 个 Patch
    // 实际场景中可能有几百个 Patch(即 Draw(numPatches * 16, 0))
    pCtx->Draw(16, 0);
    // --- 绘制结束后清理 ---
    // 避免 HS/DS 残留绑定影响后续渲染
    ID3D11HullShader*   pNullHS = nullptr;
    ID3D11DomainShader* pNullDS = nullptr;
    pCtx->HSSetShader(pNullHS, nullptr, 0);
    pCtx->DSSetShader(pNullDS, nullptr, 0);
}
// ============================================================
// HLSL 着色器代码注释说明(对应上面的 C++ 框架)
// ============================================================
// 以下是对应的 HLSL 着色器伪代码,展示 HS 和 DS 的结构
/*
==== Tessellation.hlsl ====
// ------- 常量缓冲区 -------
cbuffer PerFrameCB : register(b0)
{
    matrix gWorldViewProj;
    float  gTessFactor;   // 基础细分因子,从 C++ 传入
};
// ------- VS:透传控制点 -------
// 顶点着色器很简单,只把控制点位置传给 HS
struct VS_OUT {
    float3 posL : POSITION; // 模型空间坐标
};
VS_OUT VS(float3 posL : POSITION) {
    VS_OUT vout;
    vout.posL = posL; // 直接透传,变换在 DS 中做
    return vout;
}
// ------- HS:壳着色器 -------
// HS 的 Patch 常量函数:每个 Patch 调用一次,计算 TF
// 这里根据与摄像机的距离动态调整 TF(距离越远细分越少)
struct PatchTF {
    float EdgeTess[4]   : SV_TessFactor;        // 四边形 4 条边的 TF
    float InsideTess[2] : SV_InsideTessFactor;   // 内部 u,v 方向的 TF
};
PatchTF CalcHSPatchConstants(
    InputPatch<VS_OUT, 16> patch,  // 16 个输入控制点
    uint patchId : SV_PrimitiveID  // 当前 Patch 的 ID
) {
    PatchTF pt;
    // 简单策略:所有边和内部使用相同的全局 TF
    // 实际项目中应根据边长或与摄像机距离来计算
    float tf = gTessFactor; // 从常量缓冲区读取
    // 如果 tf <= 0,这个 Patch 会被 GPU 自动丢弃!
    pt.EdgeTess[0] = tf; // 左边 (u=0)
    pt.EdgeTess[1] = tf; // 下边 (v=0)
    pt.EdgeTess[2] = tf; // 右边 (u=1)
    pt.EdgeTess[3] = tf; // 上边 (v=1)
    pt.InsideTess[0] = tf; // u 方向内部
    pt.InsideTess[1] = tf; // v 方向内部
    return pt;
}
// HS 主函数:每个输出控制点调用一次
[domain("quad")]                           // Patch 类型:四边形
[partitioning("fractional_odd")]           // 分区类型:分数奇数(平滑过渡)
[outputtopology("triangle_cw")]            // 输出拓扑:顺时针三角形
[outputcontrolpoints(16)]                  // 输出 16 个控制点
[patchconstantfunc("CalcHSPatchConstants")] // Patch 常量函数名
VS_OUT HS(
    InputPatch<VS_OUT, 16> patch,      // 16 个输入控制点
    uint i : SV_OutputControlPointID,  // 当前要处理的控制点索引
    uint patchId : SV_PrimitiveID
) {
    // 这里可以对控制点做变换(例如位移映射)
    // 这个例子直接透传
    return patch[i];
}
// ------- DS:域着色器 -------
// 每个细分顶点调用一次
[domain("quad")] // 必须与 HS 一致
float4 DS(
    PatchTF patchTF,                    // HS 算出的 TF(通常不直接用)
    float2 uv : SV_DomainLocation,     // 细分单元给出的 (u,v) 域坐标
    const OutputPatch<VS_OUT, 16> patch // HS 输出的 16 个控制点
) : SV_Position
{
    // 用 (u,v) 对 16 个控制点做双三次贝塞尔插值
    // 这里写简化版:双线性插值(仅 4 个角点,忽略其余控制点)
    float3 p00 = patch[0].posL;   // (u=0, v=0)
    float3 p30 = patch[3].posL;   // (u=1, v=0)
    float3 p03 = patch[12].posL;  // (u=0, v=1)
    float3 p33 = patch[15].posL;  // (u=1, v=1)
    // 双线性插值:
    // p(u,v) = (1-u)(1-v)*p00 + u(1-v)*p30 + (1-u)v*p03 + uv*p33
    float3 pos = (1-uv.x)*(1-uv.y) * p00
               +    uv.x *(1-uv.y) * p30
               + (1-uv.x)*   uv.y  * p03
               +    uv.x *   uv.y  * p33;
    // 变换到裁剪空间
    return mul(float4(pos, 1.0f), gWorldViewProj);
}
*/
int main()
{
    printf("D3D11 曲面细分管线演示代码(概念验证)\n");
    printf("实际运行需要完整的 D3D11 环境初始化\n");
    printf("\n关键图元拓扑类型:\n");
    printf("  3 控制点  -> D3D11_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST\n");
    printf("  4 控制点  -> D3D11_PRIMITIVE_TOPOLOGY_4_CONTROL_POINT_PATCHLIST\n");
    printf("  16 控制点 -> D3D11_PRIMITIVE_TOPOLOGY_16_CONTROL_POINT_PATCHLIST\n");
    printf("\n细分因子范围:1.0f ~ 64.0f\n");
    printf("  TF <= 0 或 NaN -> Patch 被丢弃(廉价剔除!)\n");
    return 0;
}

十二、关键对比总结

曲面细分 vs 几何着色器(做三角形放大时)

几何着色器做三角形放大:
  输入: 1 个三角形
  GS 代码: 输出 N 个三角形
  问题:
    - 输出量可变,缓冲区管理复杂
    - 需要预留最坏情况的内存
    - ALU 浪费在控制流上,不是真正的着色
    - 每个 primitive 都要跑 GS
曲面细分做三角形放大:
  输入: 1 个 Patch(少量控制点)
  HS: 只算 TF(每 Patch 一次,很少跑)
  固定单元: 根据 TF 生成大量顶点/索引(极其高效)
  DS: 每个顶点只做纯粹的着色(类似 VS,极高效)
  优势:
    - 放大发生在固定硬件,不占 ALU
    - 缓冲区占用极小(只传 TF 和 u,v 坐标)
    - DS 和 VS 一样是"整齐"的流,GPU 满负荷运行

对比维度几何着色器(三角形放大)曲面细分管线
放大单元可编程 GS(占 ALU)固定硬件细分单元
输出量可变,难预测固定(由 TF 确定)
缓冲区管理复杂简单
着色效率低(着色器做管理工作)高(DS 只做纯着色)
内存占用高(最坏情况预留)低
缝合保证无通过 TF 机制可实现无缝

十三、公式速查

Patch 内插值(以贝塞尔为例,双线性简化版):
p(u,v)=(1−u)(1−v) p00+u(1−v) p10+(1−u)v p01+uv p11p(u, v) = (1-u)(1-v)\,p_{00} + u(1-v)\,p_{10} + (1-u)v\,p_{01} + uv\,p_{11}p(u,v)=(1−u)(1−v)p00​+u(1−v)p10​+(1−u)vp01​+uvp11​
三角形重心坐标约束:
u+v+w=1,u≥0,  v≥0,  w≥0u + v + w = 1, \quad u \geq 0,\; v \geq 0,\; w \geq 0u+v+w=1,u≥0,v≥0,w≥0
www 的计算(不需要细分单元传入):
w=1−u−vw = 1 - u - vw=1−u−v
三角形细分环数(奇数 TF):
环数=N+12,N 为奇数\text{环数} = \frac{N+1}{2}, \quad N \text{ 为奇数}环数=2N+1​,N 为奇数
三角形细分环数(偶数 TF):
环数=N2,N 为偶数(中心为顶点)\text{环数} = \frac{N}{2}, \quad N \text{ 为偶数(中心为顶点)}环数=2N​,N 为偶数(中心为顶点)

十四、关键词速查表


术语全称/含义
HSHull Shader,壳着色器(D3D11)
TCSTessellation Control Shader(OpenGL 等价于 HS)
DSDomain Shader,域着色器(D3D11)
TESTessellation Evaluation Shader(OpenGL 等价于 DS)
TFTessellation Factor,细分因子
Patch面片,由一组控制点描述的曲面单元
Quad Domain四边形参数域,用 u,v in [0,1] 描述
Tri Domain三角形参数域,用重心坐标 u,v,w 描述
Barycentric重心坐标,三角形上的参数系统
Watertight无裂缝,相邻 Patch 接缝完美对齐
Fractional-Odd分数奇数细分,在奇数 TF 间平滑过渡
Fractional-Even分数偶数细分,在偶数 TF 间平滑过渡
Patch ConstantPatch 常量,HS 计算的、对整个 Patch 相同的数据
Isoline等值线 Patch 类型,产生曲线而非曲面

图形管线之旅 2011 第十三章:计算着色器(Compute Shader)详解

原文:A trip through the Graphics Pipeline 2011, part 13(系列终章)
中文详解版,面向从零开始的读者

一、什么是计算着色器(Compute Shader)?

1.1 一句话解释

计算着色器(CS)是一种运行在 GPU 上的通用并行计算程序,它不属于图形渲染管线,可以独立运行,用来做任何需要大规模并行的计算任务。

1.2 和图形管线其他着色器的本质区别

图形管线中的每个着色器阶段(VS、HS、DS、GS、PS)都有固定的"数据驱动"方式:

  • VS 被"每个顶点"触发,输入是顶点属性
  • PS 被"每个像素/片元"触发,输入是插值后的属性
  • 它们的执行时机和输入数据由管线自动决定
    计算着色器完全不同:
图形管线着色器:
  [输入数据] -> [管线自动分发] -> [着色器执行] -> [固定位置输出]
计算着色器:
  [你手动 Dispatch] -> [线程执行] -> [随意读写任意内存(UAV)]

CS 的唯一"自动输入"是线程的编号(Thread ID),其他一切都靠程序员自己安排。

二、执行模型:线程、Warp、线程组

计算着色器的执行模型是整章的核心,理解它非常重要。分三层来看:

2.1 第一层:线程(Thread)

CS 中的"线程"和操作系统里的线程完全不同,不要混淆。

对比项OS 线程CS 线程
程序计数器(PC)有,独立没有
栈(Stack)有没有
寄存器有,独立有,独立
调度方式OS 独立调度每个线程以 Warp 为单位批量调度
类比真正的独立执行流SIMD 的一个"lane"

CS 线程更像 SIMD(单指令多数据)的一条数据通道,而不是真正独立执行的线程。

2.2 第二层:Warp(AMD 叫 Wavefront)

GPU 把若干个线程(通常 16~64 个)打包成一个 Warp,让它们完全同步地执行同一条指令,每个线程处理自己的数据。

Warp(假设 4 个线程,简化示意):
指令流:  ADD  MUL  LOAD  STORE  ...
Thread0:  A0   B0    C0    D0    ...
Thread1:  A1   B1    C1    D1    ...
Thread2:  A2   B2    C2    D2    ...
Thread3:  A3   B3    C3    D3    ...
         ↑
    同一时刻所有线程执行同一条指令,但操作自己的数据

分支(if/else)问题:如果同一个 Warp 里的线程在某个 if 条件上结果不同:

假设 Warp 里 Thread0~1 要走 if 分支,Thread2~3 要走 else 分支:
步骤 1: 执行 if 分支代码(Thread0~1 正常算,Thread2~3 屏蔽输出)
步骤 2: 执行 else 分支代码(Thread2~3 正常算,Thread0~1 屏蔽输出)
结果:两段代码都执行了一遍,效率减半

这就是Warp 分歧(Warp Divergence),计算着色器性能优化的重要考量。
调度单位是 Warp,不是线程:GPU 通过在不同 Warp 之间切换来隐藏内存延迟,而不是在线程之间切换。

2.3 第三层:线程组(Thread Group)

多个线程构成一个线程组(Thread Group)。线程组大小在着色器编译时指定,D3D11 支持 1~1024 个线程,用三维坐标 (x,y,z)(x, y, z)(x,y,z) 表示:

线程组大小示例:[numthreads(8, 8, 1)]
意思:x 方向 8 个线程,y 方向 8 个线程,z 方向 1 个线程
共 8 × 8 × 1 = 64 个线程
适合处理 8×8 的图像块(2D 纹理计算的常见做法)

整个 CS 的执行以线程组为单位 Dispatch:

// 假设要处理一张 1024x1024 的图像
// 线程组大小 [8, 8, 1],则需要 Dispatch(128, 128, 1)
// 128 * 8 = 1024,正好覆盖所有像素
pContext->Dispatch(128, 128, 1);

三层结构全貌:

Dispatch(Gx, Gy, Gz)
触发整个 CS 执行

线程组 (0,0,0)

线程组 (1,0,0)

... 共 Gx*Gy*Gz 个线程组

Warp 0
(Thread 0~31)

Warp 1
(Thread 32~63)

...

Thread (0,0,0)

Thread (1,0,0)

...

三、线程组共享内存(TGSM)

3.1 TGSM 是什么?

Thread Group Shared Memory(TGSM) 是同一个线程组内所有线程都可以读写的一块高速暂存内存(Scratchpad Memory)。
D3D11 级别硬件提供 32KB 的 TGSM。

3.2 TGSM 的硬件实现原理

关键洞察:同一个线程组内的所有 Warp,都在同一个着色器单元(Shader Unit)上执行。

着色器单元(Shader Unit)
┌─────────────────────────────────────────┐
│  ALU  ALU  ALU  ALU  ...               │
│                                         │
│  TGSM(32KB 本地暂存内存)               │
│                                         │
│  Warp 调度器                            │
│  ┌──────────────────────────────────┐   │
│  │ Warp 0 -> 执行中                 │   │
│  │ Warp 1 -> 等待内存               │   │
│  │ Warp 2 -> 等待 Barrier           │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘

因为同一时刻只有一个 Warp 在发出指令,所以每个周期内只有一组 TGSM 访问,天然不存在多个 Warp 同时写同一个地址的硬件冲突。这使得 TGSM 的硬件设计非常简单,访问速度极快。
但是:Warp 的调度顺序是不确定的,所以从程序语义角度,不能假设某个 Warp 的写操作对另一个 Warp 已经可见,必须用**屏障(Barrier)**来同步。

3.3 TGSM 典型使用场景

图像模糊(以 3x3 均值模糊为例):

不用 TGSM(慢):
  每个线程处理 1 像素
  读取周围 9 个像素 -> 9 次全局内存读取
  线程组 8x8 = 64 个线程 -> 64 * 9 = 576 次全局内存读取
  大量重复读取!
用 TGSM(快):
  每个线程先把自己负责的像素读到 TGSM
  + 读边缘像素(halo)放入 TGSM
  同步一次(Barrier)
  每个线程从 TGSM 读周围 9 个像素 -> 几乎是零延迟的本地访问
  全局内存读取次数大幅减少
TGSM 在图像处理中的典型布局(8x8 线程组,带 1 像素 halo):
线程组覆盖区域 (8x8):
  +--------+
  |        |
  | 线程组 |
  |        |
  +--------+
TGSM 存储区域 (10x10,含 halo):
  +----------+
  |          |   <- 上方 1 行 halo
  | +------+ |
  | |      | |
  | | 线程 | |   <- 核心 8x8
  | | 数据 | |
  | +------+ |
  |          |   <- 下方 1 行 halo
  +----------+
  左右各 1 列 halo

四、屏障(Barrier)详解

4.1 为什么需要屏障?

TGSM 访问虽然硬件上无冲突,但 Warp 调度是乱序的,写操作未必立刻对其他 Warp 可见(因为流水线有延迟)。屏障用来确保"在我读数据之前,所有人都已经写完了"。

4.2 三种基础同步组件

屏障由三种基础组件组合而成:
组件一:组同步(Group Synchronization)

强制线程组内所有线程到达同一个点,再一起继续。
Warp 0: [执行代码] -> 到达 Barrier -> 等待
Warp 1: [执行代码] -> 到达 Barrier -> 等待
Warp 2: [执行代码] ...(还没到)
         ...
Warp 2 终于到达 Barrier
-> 所有 Warp 同时解除等待,继续执行
代价:较低(只是调度约束,不涉及原子内存操作)

组件二:组内存屏障(Group Memory Barrier)

确保所有 TGSM 的挂起写操作都完成(管线冲刷)。
只涉及当前着色器单元内部,不需要与外部同步。
代价:较低(本地流水线冲刷)

组件三:设备内存屏障(Device Memory Barrier)

等待所有全局内存访问(包括纹理采样)完成。
GPU 全局内存延迟 > 600 个周期,甚至 > 1000 个周期!
代价:极高(会造成严重的流水线停顿)

4.3 D3D11 中的屏障 API


HLSL 内置函数组同步组内存屏障设备内存屏障
GroupMemoryBarrier()否是否
DeviceMemoryBarrier()否否是
AllMemoryBarrier()否是是
GroupMemoryBarrierWithGroupSync()是是否
DeviceMemoryBarrierWithGroupSync()是否是
AllMemoryBarrierWithGroupSync()是是是

原则:用最弱的屏障满足需求,避免不必要的 Device Memory Barrier。

五、无序访问视图(UAV)

5.1 UAV 是什么?

Unordered Access View(UAV,无序访问视图) 是计算着色器向内存写入结果的主要方式,相当于 GPU 可以随机读写的一块内存。
类比:

像素着色器的渲染目标(Render Target):
  - 只能写到"自己这个像素"的位置
  - 写入顺序由管线保证(API 顺序)
UAV:
  - 可以写到任意位置(随机访问)
  - 写入顺序在同一次 Dispatch 内不保证
  - 支持原子操作(Atomic Operations)

5.2 UAV 的"无序"是什么意思?

"无序"指的是同一次 API 调用内,不同线程对 UAV 的访问顺序不保证。但跨 API 调用之间,驱动会保证顺序性:

调用顺序:
  1. CS_A 写入 UAV_X  (写完整个资源)
  2. CS_B 读取 UAV_X  (一定能看到 CS_A 写完的结果)
驱动/API 保证:CS_B 看到的是 CS_A 完整写完后的结果,
而不是某个中间状态。

但是同一次 Dispatch 内:

CS 的 Thread 0 和 Thread 1 都写 UAV_X[42]:
  谁最后写的值生效 -> 不确定!
  这是数据竞争,需要用原子操作解决。

5.3 UAV vs 渲染目标(Render Target)


对比项渲染目标(Render Target)UAV
写入位置只能写自己对应的像素任意位置(随机访问)
读取不能在同一 pass 读可以任意读
写入顺序API 顺序严格保证同次调用内不保证
原子操作不需要(管线保证无冲突)支持(需要处理竞争)
使用位置只在 PS 阶段CS 和 PS 均可使用

六、原子操作(Atomic Operations)

6.1 为什么需要原子操作?

当多个线程同时修改同一个内存位置时,会产生数据竞争(Data Race):

Thread 0: 读取 UAV[0] = 5
Thread 1: 读取 UAV[0] = 5   <- 同时读,都读到 5
Thread 0: 计算 5 + 1 = 6
Thread 1: 计算 5 + 1 = 6
Thread 0: 写入 UAV[0] = 6
Thread 1: 写入 UAV[0] = 6   <- 两次 +1,结果应该是 7,实际是 6!

原子操作把"读-改-写"变成一个不可分割的操作,消除竞争。

6.2 CPU 的原子操作实现(对比用)

CPU 用**缓存一致性协议(Cache Coherency Protocol,如 MESI)**实现原子:

  • 写之前先获取缓存行的"独占所有权"
  • 保证同一时刻只有一个核心拥有某缓存行的写权限
  • 代价:核间通信(“缓存行 ping-pong”),即伪共享(False Sharing)
Core 0 和 Core 1 都频繁访问同一缓存行内不同的变量:
  Core 0 写 -> 获取独占 -> Core 1 的副本失效
  Core 1 写 -> 获取独占 -> Core 0 的副本失效
  ... 来回 ping-pong,性能极差

6.3 GPU 的原子操作实现

GPU 用完全不同的方案,避免了缓存一致性的复杂性:

GPU 原子操作架构:
着色器单元 (Shader Unit)
  |
  | "我要对地址 X 执行 AtomicAdd"
  v
原子单元路由(根据地址哈希决定用哪个原子单元)
  |
  +------+------+------+
  |      |      |      |
  v      v      v      v
原子单元0 原子单元1 原子单元2 原子单元3
  |
  | 独立访问最底层共享缓存
  v
最底层共享缓存(L2 Cache)
  |
  v
全局内存(VRAM)

每个原子单元"拥有"一组固定的内存地址(按地址哈希分配),对特定地址的原子操作一定由同一个原子单元处理,天然避免了并发冲突。
原子单元内部的操作流程:

1. 把目标内存位置加载到缓存(如果不在)
2. 用专用整数 ALU 执行读-改-写
3. 写回缓存
4. 在处理期间,其他对同一地址的请求会阻塞等待

原子单元处理一个操作时,所有其他对同地址的请求都要等待,但不同地址的操作可以并行(各由不同原子单元处理)。
原子操作的代价:属于"设备内存"访问,延迟很高(数百到上千个周期)。遇到 Device Memory Barrier 时必须等待它们全部完成。

七、结构化缓冲区与追加/消耗缓冲区

7.1 结构化缓冲区(Structured Buffer)

本质上是一个"有步长提示"的普通缓冲区:

普通缓冲区:        [  raw bytes  ]
结构化缓冲区:      [elem0][elem1][elem2]...
                    |每个元素固定大小(步长 stride)

对硬件来说,结构化缓冲区最终也是普通的内存访问,只是给驱动一个提示说"这些数据会按结构体为单位一起访问",驱动可以据此优化内存布局。

7.2 追加/消耗缓冲区(Append/Consume Buffer)

这是一对配套缓冲区,实现一种无锁队列的语义:

Append Buffer(追加缓冲区):
  - 多个线程可以并发地往里"追加"元素
  - 每次追加,内部指针自动原子地递增
  - 追加的顺序不保证(谁先追加就放在哪)
Consume Buffer(消耗缓冲区):
  - 多个线程可以并发地从里面"取出"元素
  - 每次消耗,内部指针自动原子地递减
  - 取出的是哪个元素不保证(不是严格的 FIFO)

实现方式:内部就是用原子操作维护一个指针(计数器),只是这个指针是缓冲区的"旁路数据(Side-band Data)",不在主资源范围内,用特殊的原子指令访问。
典型用途:粒子系统(动态添加/删除粒子)、可见性剔除的结果收集等。

八、完整架构图

可读写资源(输出)

只读资源(输入)

UAV 读写

原子操作

着色器单元内部

读写

TGSM 32KB
线程组共享内存

Warp 调度器

ALU 阵列

应用程序
调用 Dispatch(Gx,Gy,Gz)

驱动层
提交命令到 GPU

GPU 命令调度器

线程组网格
Gx * Gy * Gz 个线程组

L2 共享缓存

原子操作单元组
(按地址哈希分配)

全局显存 VRAM

常量缓冲区
Constant Buffers

着色器资源视图
Shader Resource Views
(纹理/缓冲区)

无序访问视图
UAV
(随机读写)

Append/Consume
缓冲区

九、C++ + HLSL 完整代码示例

场景:图像灰度化(并行处理每个像素)

// ============================================================
// 头文件(不使用万能头文件)
// ============================================================
#include <d3d11.h>
#include <d3dcompiler.h>
#include <cstdio>
#include <cstring>
#include <cmath>
// ============================================================
// 计算着色器:图像灰度化
// 每个线程处理一个像素
// 线程组大小:8x8x1 = 64 个线程
// ============================================================
// --------------------------------
// 常量缓冲区结构:传递图像尺寸
// --------------------------------
struct ImageInfoCB {
    unsigned int width;   // 图像宽度(像素)
    unsigned int height;  // 图像高度(像素)
    float        padding[2]; // 对齐到 16 字节
};
// ============================================================
// 核心函数:设置并执行计算着色器
// ============================================================
void RunGrayscaleCS(
    ID3D11Device*        pDevice,
    ID3D11DeviceContext* pCtx,
    ID3D11ComputeShader* pCS,          // 编译好的计算着色器
    ID3D11ShaderResourceView* pInputSRV,  // 输入图像(只读)
    ID3D11UnorderedAccessView* pOutputUAV, // 输出图像(UAV,可写)
    ID3D11Buffer* pCB,                 // 常量缓冲区
    unsigned int imageWidth,
    unsigned int imageHeight
)
{
    // --- 更新常量缓冲区 ---
    ImageInfoCB cbData = { imageWidth, imageHeight, {0.f, 0.f} };
    D3D11_MAPPED_SUBRESOURCE mapped = {};
    pCtx->Map(pCB, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
    memcpy(mapped.pData, &cbData, sizeof(cbData));
    pCtx->Unmap(pCB, 0);
    // --- 绑定计算着色器 ---
    pCtx->CSSetShader(pCS, nullptr, 0);
    // --- 绑定输入(SRV)---
    // CS 通过 SRV 只读访问输入纹理
    pCtx->CSSetShaderResources(0, 1, &pInputSRV);
    // --- 绑定输出(UAV)---
    // CS 通过 UAV 随机读写输出纹理
    UINT initialCount = 0; // 非 Append/Consume buffer,此值无意义
    pCtx->CSSetUnorderedAccessViews(0, 1, &pOutputUAV, &initialCount);
    // --- 绑定常量缓冲区 ---
    pCtx->CSSetConstantBuffers(0, 1, &pCB);
    // --- 计算需要多少个线程组 ---
    // 线程组大小是 8x8,要覆盖 imageWidth x imageHeight 的图像
    // 向上取整:ceil(imageWidth / 8)
    // 例如:图像 1024x768,需要 Dispatch(128, 96, 1)
    unsigned int groupsX = (imageWidth  + 7) / 8; // (width + 7) / 8 = ceil
    unsigned int groupsY = (imageHeight + 7) / 8;
    // --- 发起 Dispatch ---
    // 这里触发 groupsX * groupsY * 1 个线程组
    // 每个线程组 8*8*1 = 64 个线程
    // 总线程数 = groupsX * 8 * groupsY * 8 (可能略多于像素数,边界要在 CS 里处理)
    pCtx->Dispatch(groupsX, groupsY, 1);
    // --- 执行完毕,解绑资源 ---
    // 重要:必须解绑 UAV,否则后续管线可能无法把它作为 SRV 使用
    ID3D11UnorderedAccessView* pNullUAV = nullptr;
    ID3D11ShaderResourceView*  pNullSRV = nullptr;
    pCtx->CSSetUnorderedAccessViews(0, 1, &pNullUAV, &initialCount);
    pCtx->CSSetShaderResources(0, 1, &pNullSRV);
    pCtx->CSSetShader(nullptr, nullptr, 0);
}
// ============================================================
// 展示 Append/Consume Buffer 用法(粒子更新场景伪代码)
// ============================================================
void RunParticleUpdateCS(
    ID3D11DeviceContext* pCtx,
    ID3D11ComputeShader* pCS,
    ID3D11UnorderedAccessView* pAliveListUAV,   // 活粒子列表(Consume)
    ID3D11UnorderedAccessView* pNewAliveListUAV, // 更新后的活粒子(Append)
    unsigned int numParticles
)
{
    // 绑定着色器
    pCtx->CSSetShader(pCS, nullptr, 0);
    // 绑定两个 UAV(槽 0 和槽 1)
    // initialCount[0] = -1: 不重置 Consume Buffer 的计数器(继续上次)
    // initialCount[1] =  0: 重置 Append Buffer 计数器为 0(从空开始追加)
    ID3D11UnorderedAccessView* uavs[2] = { pAliveListUAV, pNewAliveListUAV };
    UINT initialCounts[2] = { (UINT)-1, 0 };
    pCtx->CSSetUnorderedAccessViews(0, 2, uavs, initialCounts);
    // 每个线程处理一个粒子
    // 线程组 64 个线程,向上取整
    unsigned int groups = (numParticles + 63) / 64;
    pCtx->Dispatch(groups, 1, 1);
    // 解绑
    ID3D11UnorderedAccessView* nullUAVs[2] = { nullptr, nullptr };
    UINT nullCounts[2] = { 0, 0 };
    pCtx->CSSetUnorderedAccessViews(0, 2, nullUAVs, nullCounts);
    pCtx->CSSetShader(nullptr, nullptr, 0);
}
// ============================================================
// main:演示代码(实际运行需完整 D3D11 初始化)
// ============================================================
int main()
{
    printf("计算着色器(Compute Shader)关键参数演示\n\n");
    // --- 演示线程组数量计算 ---
    unsigned int imageWidth  = 1024;
    unsigned int imageHeight = 768;
    unsigned int threadGroupSizeX = 8;
    unsigned int threadGroupSizeY = 8;
    // 向上取整:(n + groupSize - 1) / groupSize
    unsigned int groupsX = (imageWidth  + threadGroupSizeX - 1) / threadGroupSizeX;
    unsigned int groupsY = (imageHeight + threadGroupSizeY - 1) / threadGroupSizeY;
    printf("图像尺寸: %u x %u\n", imageWidth, imageHeight);
    printf("线程组大小: [%u, %u, 1] = %u 线程/组\n",
        threadGroupSizeX, threadGroupSizeY,
        threadGroupSizeX * threadGroupSizeY);
    printf("Dispatch 参数: (%u, %u, 1)\n", groupsX, groupsY);
    printf("总线程组数: %u\n", groupsX * groupsY);
    printf("总线程数: %u(覆盖 %u 像素,多 %u 个线程需在 CS 里丢弃)\n",
        groupsX * groupsY * threadGroupSizeX * threadGroupSizeY,
        imageWidth * imageHeight,
        groupsX * groupsY * threadGroupSizeX * threadGroupSizeY
            - imageWidth * imageHeight);
    printf("\n--- 屏障选择建议 ---\n");
    printf("只访问 TGSM   -> GroupMemoryBarrierWithGroupSync()\n");
    printf("访问 UAV/全局 -> DeviceMemoryBarrierWithGroupSync()\n");
    printf("两者都有      -> AllMemoryBarrierWithGroupSync()\n");
    printf("(避免不必要的 DeviceMemoryBarrier,代价极高!)\n");
    return 0;
}

对应的 HLSL 计算着色器(注释版)

// ============================================================
// Grayscale.hlsl —— 图像灰度化计算着色器
// ============================================================
// --- 常量缓冲区(从 C++ 传入图像尺寸)---
cbuffer ImageInfoCB : register(b0)
{
    uint gWidth;    // 图像宽度
    uint gHeight;   // 图像高度
    float2 padding;
};
// --- 输入纹理(SRV,只读,RGBA 格式)---
Texture2D<float4> gInputTex : register(t0);
// --- 输出纹理(UAV,可读写,RGBA 格式)---
RWTexture2D<float4> gOutputTex : register(u0);
// --- 线程组共享内存(TGSM)---
// 这里简单示例:存储 8x8 块的亮度值,供组内线程共享
// 实际灰度化不需要 TGSM,这里仅演示用法
groupshared float tileCache[8][8];
// --- 计算着色器主函数 ---
// [numthreads(8, 8, 1)] 指定线程组大小:8 * 8 * 1 = 64 个线程
[numthreads(8, 8, 1)]
void CS_Grayscale(
    // SV_DispatchThreadID:当前线程在整个 Dispatch 里的绝对坐标
    // 等于 groupID * groupSize + threadID
    uint3 dispatchID : SV_DispatchThreadID,
    // SV_GroupThreadID:当前线程在线程组内的局部坐标 (0~7, 0~7, 0)
    uint3 groupThreadID : SV_GroupThreadID,
    // SV_GroupID:当前线程组的坐标
    uint3 groupID : SV_GroupID
)
{
    // 取出当前线程对应的像素坐标
    uint2 pixel = dispatchID.xy;
    // 边界检查:线程组可能比图像多一些,越界的线程直接退出
    // 例如:图像 1024x768,groupsX=128,覆盖 1024 列,刚好;
    //       groupsY=96,覆盖 768 行,刚好。无越界。
    // 但图像 1023x767 时,groupsX=128 覆盖 1024 列,最右列越界。
    if (pixel.x >= gWidth || pixel.y >= gHeight)
        return; // 越界线程直接退出,不做任何操作
    // --- 读取输入像素颜色(RGBA)---
    float4 color = gInputTex[pixel];
    // --- 计算灰度值(亮度公式,基于人眼感知权重)---
    // 公式:L = 0.2126 * R + 0.7152 * G + 0.0722 * B
    // 这是 Rec. 709 标准的亮度系数
    float luminance = dot(color.rgb, float3(0.2126f, 0.7152f, 0.0722f));
    // --- 存入 TGSM(演示 TGSM 用法)---
    // 把亮度值存入线程组内共享内存
    // 索引用线程组内的局部坐标
    tileCache[groupThreadID.y][groupThreadID.x] = luminance;
    // --- 组同步 Barrier ---
    // 确保所有线程都写完 TGSM 再继续
    // 这里用 GroupMemoryBarrierWithGroupSync():
    //   - 组同步(等所有线程到这里)
    //   - 组内存屏障(确保 TGSM 写入可见)
    // 不用 DeviceMemoryBarrier(没有访问全局内存需要等待)
    GroupMemoryBarrierWithGroupSync();
    // --- 从 TGSM 读取(演示)---
    // 这里简单读自己存的值(实际场景会读周围像素的值,例如做模糊)
    float cachedLuminance = tileCache[groupThreadID.y][groupThreadID.x];
    // --- 写出结果到 UAV ---
    // 灰度图:R=G=B=亮度,A 保持原样
    gOutputTex[pixel] = float4(cachedLuminance, cachedLuminance, cachedLuminance, color.a);
}
// ============================================================
// AppendConsume.hlsl —— 粒子更新:消耗旧列表,追加到新列表
// ============================================================
struct Particle {
    float3 position;
    float3 velocity;
    float  lifetime;
    float  pad;
};
// Consume Buffer:从旧的活粒子列表取粒子
ConsumeStructuredBuffer<Particle> gAliveList    : register(u0);
// Append Buffer:把更新后仍然存活的粒子追加到新列表
AppendStructuredBuffer<Particle>  gNewAliveList : register(u1);
cbuffer TimeCB : register(b0) { float gDeltaTime; float3 pad; };
[numthreads(64, 1, 1)]
void CS_UpdateParticles(uint3 id : SV_DispatchThreadID)
{
    // 从 Consume Buffer 取出一个粒子(自动原子递减内部计数器)
    Particle p = gAliveList.Consume();
    // 更新粒子
    p.position += p.velocity * gDeltaTime;
    p.lifetime -= gDeltaTime;
    // 如果粒子还存活,追加到新列表(自动原子递增内部计数器)
    if (p.lifetime > 0.0f)
    {
        gNewAliveList.Append(p);
    }
    // 如果死亡,不追加——粒子就这样消失了
}

十、延迟隐藏:Warp 切换的直觉理解

GPU 隐藏内存延迟的方式和 CPU 完全不同:

CPU 的方式(乱序执行 + 预取):
  一个线程,CPU 预测未来需要什么数据,提前发出 Load
  同时继续执行其他不依赖该数据的指令
  代价:复杂的乱序执行单元,大量晶体管
GPU 的方式(大量 Warp 切换):
  Warp 0 发出 Load(全局内存,延迟 >600 周期)
  -> Warp 0 标记为"等待",切换到 Warp 1
  Warp 1 执行若干周期,发出 Load
  -> 切换到 Warp 2
  ... 
  Warp 0 的 Load 终于返回
  -> 切换回 Warp 0,继续执行
  效果:用大量 Warp(大量"并发"工作)掩盖延迟
  代价:每个 Warp 需要保存自己的寄存器(占用大量寄存器文件空间)

所以:用寄存器换延迟隐藏。寄存器用得越多,同一个着色器单元能容纳的 Warp 数就越少,延迟隐藏能力就越弱(称为"占用率 Occupancy 下降")。

十一、关键性能总结


操作大致代价说明
TGSM 读写~1-4 周期本地暂存,极快
GroupMemoryBarrier低本地流水线冲刷
GroupSync(同步屏障)低-中调度约束,等最慢的 Warp
DeviceMemoryBarrier极高等全局内存,>600 周期
全局内存(UAV)读写>600 周期(无缓存)被 Warp 切换隐藏
原子操作高需要访问原子单元+共享缓存
Warp 分歧(分支不一致)中两段代码都执行,效率减半

十二、整体概念速查


术语含义
CSCompute Shader,计算着色器
ThreadCS 中的线程,类似 SIMD Lane,没有独立 PC/栈
Warp / Wavefront一组同步执行的线程(16~64 个),是实际调度单位
Thread Group线程组,共享 TGSM,在同一着色器单元上执行
TGSMThread Group Shared Memory,32KB 高速共享内存
Dispatch触发 CS 执行的 API 调用,参数是线程组数量
UAVUnordered Access View,可随机读写的资源视图
SRVShader Resource View,只读资源视图
Barrier屏障,用于同步线程执行顺序和内存可见性
Atomic原子操作,不可分割的读-改-写,用于多线程共享内存
False Sharing伪共享,CPU 中不相关数据共享缓存行导致的性能问题
Occupancy占用率,着色器单元上同时活跃的 Warp 数量
Structured Buffer结构化缓冲区,固定步长的元素数组
Append Buffer追加缓冲区,线程安全地向末尾添加元素
Consume Buffer消耗缓冲区,线程安全地从中取出元素

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐