一次 VS 输出与 GS 输入不匹配引发的显示与性能问题
一次 VS 输出与 GS 输入不匹配引发的显示与性能问题
这是一篇调试记录:某主机平台上点光源阴影渲染莫名变得极慢、还伴随显示破损,最后查出根因是 Vertex Shader 的输出结构和 Geometry Shader 的输入结构不匹配,导致 GS 阶段生成了大量无效图元。方法本身和平台无关,记下来当案例。
问题表现
特定区域 GPU 消耗异常升高、帧率明显下降。GPU trace 显示瓶颈在渲染点光源(PointLight)ShadowMap 上——渲染某个角色风格体时单这一项就超过 50ms,而在另一台对照主机平台上同样的内容只花 1ms。两台平台跑同一套引擎和 shader,50 倍差距明显不正常。
调查步骤
抓帧分析,引擎走的是 OnePassPointLightGS:用一次 draw、在 Geometry Shader 里借助视图投影矩阵把网格体投到点光源 cubemap 的 6 个面上,一趟渲染出多面的阴影贴图。GS 主体大致是:
struct VsOut
{
float4 GSPos : TEXCOORD6;
};
float4x4 ViewCubeMapMatrix[6];
float4x4 ProjectionMatrix;
[MAX_VERTEX_COUNT(18)]
void main(Triangle VsOut input[3], inout TriangleBuffer<PsIn> GSCubeMapTriBuffer)
{
for (int f = 0; f < 6; ++f)
{
PsIn output;
output.TargetIndex = f;
for (int v = 0; v < 3; v++)
{
output.pos = mul(input[v].GSPos, ViewCubeMapMatrix[f]);
output.pos = mul(output.GSPos, ProjectionMatrix);
GSCubeMapTriBuffer.Append(output);
}
GSCubeMapTriBuffer.RestartStrip();
}
}
1. 对比两个平台的输出。 GPU capture 里检查对应 draw,发现出问题的平台 depth render target 上的输出有异常。编辑器里看到这盏灯放在模型内部,临时把灯挪到模型外面,再对比两个平台的 depth 输出:

左边是出问题平台的输出,模型明显已经破损;右边对照平台正常。
2. 排除 shader 计算本身的错误。 当时该平台的 GPU profiler 还不支持直接查看 Geometry Shader 的输出,没法一眼看出破损原因。于是层层排除:
- 通过平台厂商的技术支持确认,性能瓶颈是因为 GS 输出后生成了大量 depth 采样;
- 对比两个平台的 shader 代码,没有差异;
- 用 RenderDoc 抓了 PC 的 capture,把出问题平台里 VS 和 GS 的 constant buffer 导进 RenderDoc,确认 constant buffer 是正确的;
- 用该平台的 shader debugger 调 GS,顶点计算后的输出值和 RenderDoc 里 GS 的输出基本一致——说明 shader 计算本身没问题。
3. 锁定异常图元。 在 GPU replay 时改 GS 代码,加个条件只输出明显异常的 primitive:
if (input[1].pos.x - input[0].pos.x > 10) // 只看跨度异常大的图元
调试发现 GS 输入的部分 primitive 里,三个顶点中有两个坐标值完全一致:
x 26.920013 y -1.2841797 z 56.755623 w 57.698883
x -21.102783 y 0.6359863 z 52.35645 w 53.304108
x -21.102783 y 0.6359863 z 52.35645 w 53.304108
4. 用 padding 验证猜想。 尝试给 GS 输入的 VsOut 加一个 float4 padding:
struct VsOut
{
float4 GSPos : TEXCOORD6;
float4 padding;
};
重新 replay 后显示正常、耗时降回 1ms 左右。基本可以确定:VS 输出和 GS 输入的内存布局对不上,GS 读到了错位的顶点数据,生成了大量无效图元。
问题修复
回头看 VS 这边声明的输出:
struct VsOut
{
float4 GSPos : TEXCOORD6;
};
void MainForGS(FVertexFactoryInput Input,
out VsOut OutParameters,
out float4 OutPosition : S_POSITION)
VS 多输出了一个 OutPosition,而 GS 的输入结构里没有对应字段,于是两边布局错位。在其他版本引擎代码里确认,这些主机平台的 VS 其实不需要输出 OutPosition,可以用宏把它屏蔽掉:
void MainForGS(
OPTIONAL_VertexID
FVertexFactoryInput Input,
#if USING_TESSELLATION
out FShadowDepthVSToDS OutParameters
#elif ONEPASS_POINTLIGHT_SHADOW
out FShadowDepthVSToGS OutParameters
#if FEATURE_LEVEL >= FEATURE_LEVEL_SM4 && USING_VERTEX_SHADER_LAYER
, out uint LayerIndex : SV_RenderTargetArrayIndex
#endif
#else
#error Invalid permutation for MainForGS
#endif
#if COMPILER_METAL
#define SKIP_UNUSED_POSITION
// Metal 需要一个 position 输出
, out float4 UnusedPosition : SV_POSITION
#endif
)
{
去掉多余的 OutPosition 输出后,VS 输出与 GS 输入对齐,显示恢复正常、性能回到正常水平。
总结
- 先用 GPU trace 定位性能瓶颈,再根据瓶颈确定调查方向——本例瓶颈指向 GS 输出后的海量 depth 采样,于是聚焦在 GS 的输入输出上。
- 重点检查 shader 各阶段之间的输入/输出布局是否对齐。VS 的输出结构和 GS 的输入结构一旦字段对不上,GS 会读到错位数据,既显示破损又会爆量无效图元拖垮性能,而 shader 单独算又「看起来没错」,很有迷惑性。
- 当平台的 GPU 工具不支持查看某个 shader 阶段的输出时,多管齐下交叉验证:厂商支持、RenderDoc 导入 constant buffer、shader debugger 单步、replay 时改 GS 加过滤条件、以及用 padding 试探内存布局。
- 平台提供的 Shader Compiler / 反汇编界面能直接核对 shader 的输入输出签名,是确认两阶段是否匹配的最快途径。