A trip through the Graphics Pipeline 2011学习(续)
图形管线漫游 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 单元的完整工作流:
混合公式(典型 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 层面的明确同步原语来处理。
七、整体管线最终流程
八、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
九、为什么混合不能完全可编程:决策树
十、关键概念总结
| 概念 | 一句话解释 |
|---|---|
| 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 在管线中的位置
注意 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 到 NmaxN_{\max}Nmax 个顶点,NmaxN_{\max}Nmax 在编译时固定。
输出的顶点数量运行时可变,这使得下游管线无法提前知道要处理多少图元。
这带来了一个问题:
并行 GS 调用数∝可用缓冲区大小Nmax×每顶点属性大小
\text{并行 GS 调用数} \propto \frac{\text{可用缓冲区大小}}{N_{\max} \times \text{每顶点属性大小}}
并行 GS 调用数∝Nmax×每顶点属性大小可用缓冲区大小
NmaxN_{\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 本身的执行,只影响下游:
| 功能 | 全称 | 作用 | 在哪里被消费 |
|---|---|---|---|
| VPAI | Viewport Array Index | 每个图元选择一个视口和裁剪矩形 | 视口变换阶段 |
| RTAI | Render-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 管线完整流程
十、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 = █
tri0.indices = {0, 1, 2, 0, 0, 0};
tri0.indexCount = 3;
tri0.primitiveId = 0;
GsInputPrimitive tri1;
tri1.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
十一、各着色器阶段对比
| 特性 | VS | GS | PS |
|---|---|---|---|
| 输入粒度 | 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 系统接收。
下面是整个流程图:
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 | 可以继续往下走渲染管线(光栅化等) |
流程如下:
说明:一个 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 不需要参与
注意:Query 机制依然存在(用于调试、检测是否溢出等),但它不在关键渲染路径上,不影响性能。
五、完整概念总结
SO 核心知识点速查
| 知识点 | 说明 |
|---|---|
| SO 是什么 | 把着色器输出保存到内存缓冲区,而非继续走渲染管线 |
| NULL GS SO | 把 VS 字节码传给 CreateGeometryShaderWithStreamOutput |
| SO 声明要一致 | 保证声明与输出声明相同,避免重排序开销 |
| SO 带宽有限 | 只有一个内存通道,且只支持全精度浮点 |
| SO 操作图元 | 输入是装配好的图元,不是单个顶点 |
| 点列表技巧 | 蒙皮 pass 用点列表,避免顶点重复展开 |
| 多流(D3D11) | GS 最多 4 个流,一对多写 SO 目标,最多一个流进光栅化 |
| DrawAuto | GPU 自持计数器,避免 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−1wi⋅Mi⋅pbind
其中:
- pbind\mathbf{p}_{bind}pbind 是顶点在绑定姿态(T-Pose)下的位置
- Mi\mathbf{M}_iMi 是第 iii 根骨骼的变换矩阵(从绑定空间到当前动画空间)
- wiw_iwi 是该骨骼对该顶点的权重,满足 ∑iwi=1\sum_{i} w_i = 1∑iwi=1
- nnn 通常取 4(每顶点最多 4 根骨骼影响)
如果同一帧需要用同一姿势绘制多次(如主渲染 + 阴影),每次绘制都重新算一遍蒙皮代价很高。用 SO 把 pskinned\mathbf{p}_{skinned}pskinned 缓存下来,后续 pass 直接用,节省了 O(n×V)O(n \times V)O(n×V) 的矩阵乘法(VVV 为顶点数)。
八、关键词速查表
| 术语 | 全称/含义 |
|---|---|
| SO | Stream-Out,流输出 |
| GS | Geometry Shader,几何着色器 |
| VS | Vertex Shader,顶点着色器 |
| NULL GS | 没有真正 GS 逻辑的"假 GS 对象" |
| SO Target | SO 目标缓冲区,接收 SO 写入的内存 |
| SO Declaration | SO 声明,指定哪些属性写到哪个槽 |
| DrawAuto | 用 GPU 内部计数器代替 CPU 传入顶点数的绘制命令 |
| Primitive Assembly | 图元装配,把顶点组合成三角形/线段等 |
| Skinning | 蒙皮,用骨骼矩阵变换顶点 |
| Round-Trip | GPU→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 的曲面细分管线由三个部分组成,插入在顶点着色器和几何着色器之间:
| 阶段 | D3D11 叫法 | OpenGL 叫法 | 是否可编程 |
|---|---|---|---|
| 壳着色器 | Hull Shader (HS) | Tessellation Control Shader (TCS) | 是 |
| 细分单元 | Tessellator | Tessellator | 否(固定硬件) |
| 域着色器 | 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 整体数据流
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 为偶数(中心为顶点)
十四、关键词速查表
| 术语 | 全称/含义 |
|---|---|
| HS | Hull Shader,壳着色器(D3D11) |
| TCS | Tessellation Control Shader(OpenGL 等价于 HS) |
| DS | Domain Shader,域着色器(D3D11) |
| TES | Tessellation Evaluation Shader(OpenGL 等价于 DS) |
| TF | Tessellation Factor,细分因子 |
| Patch | 面片,由一组控制点描述的曲面单元 |
| Quad Domain | 四边形参数域,用 u,v in [0,1] 描述 |
| Tri Domain | 三角形参数域,用重心坐标 u,v,w 描述 |
| Barycentric | 重心坐标,三角形上的参数系统 |
| Watertight | 无裂缝,相邻 Patch 接缝完美对齐 |
| Fractional-Odd | 分数奇数细分,在奇数 TF 间平滑过渡 |
| Fractional-Even | 分数偶数细分,在偶数 TF 间平滑过渡 |
| Patch Constant | Patch 常量,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);
三层结构全貌:
三、线程组共享内存(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)",不在主资源范围内,用特殊的原子指令访问。
典型用途:粒子系统(动态添加/删除粒子)、可见性剔除的结果收集等。
八、完整架构图
九、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 分歧(分支不一致) | 中 | 两段代码都执行,效率减半 |
十二、整体概念速查
| 术语 | 含义 |
|---|---|
| CS | Compute Shader,计算着色器 |
| Thread | CS 中的线程,类似 SIMD Lane,没有独立 PC/栈 |
| Warp / Wavefront | 一组同步执行的线程(16~64 个),是实际调度单位 |
| Thread Group | 线程组,共享 TGSM,在同一着色器单元上执行 |
| TGSM | Thread Group Shared Memory,32KB 高速共享内存 |
| Dispatch | 触发 CS 执行的 API 调用,参数是线程组数量 |
| UAV | Unordered Access View,可随机读写的资源视图 |
| SRV | Shader Resource View,只读资源视图 |
| Barrier | 屏障,用于同步线程执行顺序和内存可见性 |
| Atomic | 原子操作,不可分割的读-改-写,用于多线程共享内存 |
| False Sharing | 伪共享,CPU 中不相关数据共享缓存行导致的性能问题 |
| Occupancy | 占用率,着色器单元上同时活跃的 Warp 数量 |
| Structured Buffer | 结构化缓冲区,固定步长的元素数组 |
| Append Buffer | 追加缓冲区,线程安全地向末尾添加元素 |
| Consume Buffer | 消耗缓冲区,线程安全地从中取出元素 |
更多推荐



所有评论(0)