Skip to main content

Chapter 22: Vertex Shaders In Depth

Vertex Shader 是一次 draw call 进入可编程图形管线后最早处理 mesh 顶点数据的 shader stage。读完本章后,读者应能追踪一个顶点从 vertex buffer 进入 shader、被变换到裁剪空间、携带属性进入后续插值、再影响画面和性能的完整路径,并能判断一个顶点阶段问题应从输入布局、矩阵空间、属性计算、实例数据、LOD 或 API 语义哪一层排查。

本章使用同一个贯穿材料:一个 draw call 渲染一批带位置、法线、切线、UV、骨骼权重和 instance transform 的角色道具。这个 draw call 的 vertex shader 读取 per-vertex attributes,读取 per-frame 或 per-object buffer,输出裁剪空间位置和若干 varyings。画面表现可能是模型位置错误、法线光照翻转、动画抖动、实例位置错乱、UV 拉伸或顶点阶段耗时过高。所有小节都围绕这条数据路径展开。

Vertex Shader 的核心结论可以先固定为一句话:它负责把“资产坐标中的顶点记录”转换成“后续管线可裁剪、可光栅化、可插值的数据”。位置输出决定图元会出现在屏幕哪里,属性输出决定 fragment shader 后续拿到什么插值数据,输入布局和资源绑定决定 shader 读到的对象是否符合预期,顶点调用次数和每次调用的内存/算术成本决定这一阶段是否成为性能瓶颈。

22.1 Vertex Shader 在渲染流水线中的角色

Vertex Shader 的工作边界由一次 draw call 的输入和输出决定。输入侧来自 vertex buffer、index buffer、instance buffer、uniform buffer、constant buffer 或 push constant;输出侧至少包含一个裁剪空间位置,并可附带颜色、UV、法线、切线、世界坐标、视线方向等 varyings。Varying 是 vertex shader 写出的用户属性,光栅化阶段会按三角形覆盖关系对它们做插值,再交给 fragment shader 使用。

用贯穿 draw call 表示,CPU 侧先绑定 pipeline state、vertex buffers、index buffer、descriptor 或 argument buffer,然后提交 draw。GPU 固定功能阶段根据 index buffer 取出顶点编号,再根据 vertex input layout 从 buffer 中解包位置、法线、UV 和其他属性。Vertex Shader 以“每个顶点调用一次”为基础执行模型,读取当前顶点记录和全局常量,写出 gl_PositionSV_Position[[position]] 或 SPIR-V BuiltIn Position 对应的裁剪空间位置。

下面的图只表达 vertex shader 在一个 draw call 中的接口位置。它不覆盖 tessellation、geometry shader 和 mesh shader 的额外路径。

这条路径中,Vertex Fetch 负责按输入布局读取字节;Vertex Shader 负责解释这些值并完成数学计算;Vertex Post Processing 使用位置输出做裁剪、透视除法、viewport 变换和顶点缓存相关处理;光栅化阶段根据三角形覆盖产生 fragment,并对 varyings 做插值。画面错误要沿着这条链回溯,先判断错误出现在“读错输入”“算错空间”“写错输出”还是“后续阶段按正确输出继续处理”。

一个最小 GLSL 版本可以展示 vertex shader 的接口形态。示例只保留位置、法线、UV 和 MVP 变换,目的是把输入属性、uniform 数据和输出 varying 的关系固定下来。

#version 450

layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inUV;

layout(set = 0, binding = 0) uniform FrameData {
mat4 viewProj;
} frameData;

layout(set = 1, binding = 0) uniform ObjectData {
mat4 model;
mat3 normalMatrix;
} objectData;

layout(location = 0) out vec3 vWorldNormal;
layout(location = 1) out vec2 vUV;

void main() {
vec4 worldPosition = objectData.model * vec4(inPosition, 1.0);
gl_Position = frameData.viewProj * worldPosition;
vWorldNormal = normalize(objectData.normalMatrix * inNormal);
vUV = inUV;
}

这段代码的关键点在于 gl_PositionvWorldNormal 的语义差异。gl_Position 是后续裁剪和屏幕映射的主输入,写错会直接改变图元的位置、大小、裁剪结果和深度。vWorldNormal 是后续 fragment shader 的插值输入,写错通常表现为光照方向异常、明暗翻转或法线贴图基底错误。OpenGL Wiki 对 vertex shader 预定义输出的说明把 gl_Position 定义为当前顶点的裁剪空间输出位置;Vulkan 规范也把 shader stage 之间的用户变量匹配、vertex input interface 和内建变量接口拆成明确规则,可分别查看 OpenGL Vertex ShaderVulkan Shader Interfaces

调试 vertex shader 角色时,先固定四个观察点:vertex input layout 是否和 buffer 实际字节布局一致;每个 attribute 的坐标空间是否写清;gl_Position 或同类位置输出是否处在裁剪空间;varyings 的数量、精度和插值方式是否符合 fragment shader 需求。多数顶点阶段问题会在这四个点上暴露证据。

22.2 坐标变换与顶点属性计算逻辑

Vertex Shader 中最常见的主路径是坐标变换。模型资产里的 inPosition 通常处在 model space;对象的 model 矩阵把它变到 world space;相机的 view 矩阵把它变到 view space;投影矩阵把它变到 clip space。工程里常把 projection * view * model 合成 MVP 矩阵,但调试时应拆开看每个空间的输入输出,因为模型错位、相机反向、深度异常和裁剪错误分别对应不同阶段。

位置计算使用齐次坐标。vec4(inPosition, 1.0) 表示这个值参与平移,输出裁剪坐标中的 w 分量会参与透视除法。一个顶点在 vertex shader 中写出的 clip position 还没有成为屏幕像素坐标;后续固定功能阶段会执行 ndc = clip.xyz / clip.w,再由 viewport state 映射到屏幕范围。调试位置时,观察顺序应是 model space 数值范围、world position、view space 深度符号、clip w、NDC 范围、viewport 和 depth range。

法线计算和位置计算使用不同规则。位置要经过完整模型矩阵,法线表示局部表面方向,遇到非等比缩放时使用 model 矩阵左上三阶部分直接变换会改变法线与表面的垂直关系。工程上通常使用 normal matrix,也就是 model 矩阵三阶部分的逆转置矩阵。若对象只包含旋转和等比缩放,直接使用旋转部分常能得到一致结果;若存在非等比缩放,normal matrix 是稳定做法。

贯穿 draw call 中的角色道具还可能使用 skinning。Skinning 的输入通常包括每个顶点若干 joint index 和 joint weight,shader 根据这些索引读取骨骼矩阵,把同一个静态顶点混合到当前姿态。计算可写成加权矩阵或加权变换结果,关键约束是权重归一化、索引范围正确、骨骼矩阵空间统一、bind pose 和 inverse bind matrix 组合顺序一致。

layout(location = 3) in uvec4 inJointIndex;
layout(location = 4) in vec4 inJointWeight;

layout(set = 2, binding = 0) readonly buffer SkinData {
mat4 jointMatrix[];
} skinData;

vec4 skinPosition(vec3 localPosition) {
vec4 p = vec4(localPosition, 1.0);
vec4 result = vec4(0.0);
result += inJointWeight.x * (skinData.jointMatrix[inJointIndex.x] * p);
result += inJointWeight.y * (skinData.jointMatrix[inJointIndex.y] * p);
result += inJointWeight.z * (skinData.jointMatrix[inJointIndex.z] * p);
result += inJointWeight.w * (skinData.jointMatrix[inJointIndex.w] * p);
return result;
}

这段 skinning 代码回答的是“当前顶点在动画姿态下处于哪里”。它引入的风险来自 buffer 读取和矩阵空间。若 joint index 指向错误位置,模型会出现局部飞点或整块网格被拉走;若权重总和偏离 1,动画会有比例变化;若 joint matrix 混用了 world space 和 model space,角色会跟随骨骼产生整体错位。排查时先输出未蒙皮位置,再输出单个骨骼影响,再恢复四骨骼混合。

Morph target 的输入路径不同。Morph target 通常为每个顶点提供 delta position、delta normal 或更多形变通道,shader 按权重叠加到 base mesh 上。它改变的是顶点属性本身,常用于表情、肌肉形变或破损形态。Morph 发生在 model space 时,后续仍要走 model/view/projection;若在 world space 叠加 delta,就会把对象变换和形变语义混在一起,后续实例化和动画复用会变得难以验证。

Instancing 把“同一套顶点数据渲染多次”映射成 per-instance 数据读取。贯穿 draw call 中,一把剑、一块石头或一丛草可能共用同一个 mesh buffer,但每个实例拥有自己的 transform、颜色、材质索引或 wind 参数。Vertex Shader 读取 instanceID 或 instance attribute,选择当前实例的数据,再和 per-vertex attribute 合成最终输出。实例问题常表现为所有实例重叠在原点、实例顺序错乱、材质索引偏移或只有第一个实例正确。

layout(location = 5) in vec4 instanceModel0;
layout(location = 6) in vec4 instanceModel1;
layout(location = 7) in vec4 instanceModel2;
layout(location = 8) in vec4 instanceModel3;

mat4 readInstanceModel() {
return mat4(instanceModel0, instanceModel1, instanceModel2, instanceModel3);
}

实例矩阵的读取还会暴露行主序、列主序和 attribute 拆分问题。GLSL 的 mat4 构造在语义上按列向量组织;HLSL 和宿主侧数学库可能采用不同书写习惯。稳定做法是把 CPU 写 buffer 的矩阵内存顺序、shader 读取方式和矩阵乘法方向放在同一个测试里验证:先用单位矩阵,再用只含平移的矩阵,再用只含旋转的矩阵,最后测试非等比缩放和法线。

Vertex Shader 还负责产生后续插值所需的属性。UV 通常原样传递;world normal 需要归一化后输出;tangent space 可能需要输出切线、法线和 handedness;world position 常用于 fragment shader 中计算视线方向或阴影坐标。这里的设计取决于 fragment shader 需要在哪个空间做计算。若光照在 world space 中完成,vertex shader 输出 world normal 和 world position 会降低 fragment shader 的重复计算;若需要 per-pixel 精确法线,fragment shader 仍要对插值后的向量重新归一化。

22.3 优化 Vertex Shader 或减少工作量

Vertex Shader 优化要先判断瓶颈类型。顶点阶段耗时可能来自调用次数过多、attribute 带宽过高、buffer 读取分散、skinning 算术密集、矩阵数据重复读取、实例数据布局不友好,或者真正瓶颈在 CPU 提交和 draw call 组织。稳定的判断顺序是先看 GPU capture 中 draw 的 vertex count、instance count、vertex shader duration 和 pipeline statistics,再看 attribute layout、shader 指令、buffer 访存和 post-transform cache 命中表现。

减少工作量的第一层是减少 vertex shader invocation 数量。LOD 通过替换低复杂度 mesh 降低远处对象的顶点数量;GPU culling 通过 compute pass 或 indirect draw 参数压缩不可见对象;meshlet 或 cluster culling 通过更小的空间单元减少提交到顶点阶段的无效几何;skinned mesh 可按距离切换到低骨骼数、低顶点数或 impostor 表示。这里的收益来自少执行 shader,而非让同一段 shader 单次执行更快。

第二层是降低 attribute 带宽。一个顶点记录可能包含 position、normal、tangent、UV、color、joint index、joint weight 和自定义数据。若每个顶点都用 32-bit float 存储所有属性,顶点读取会占用更多内存带宽和 cache 空间。常见压缩方式包括半精度 UV、10:10:10:2 法线/切线编码、16-bit index、量化 position、8-bit joint weight。压缩的边界是解码成本、精度误差和资产范围;近景角色脸部、硬表面法线和大世界坐标要保留更高精度。

第三层是把稳定计算前移。静态 mesh 的 tangent、normal、bounding volume、LOD、meshlet 和压缩数据应在导入阶段或 build 阶段生成;每帧不变化的 object matrix 可在 CPU 或 compute pass 侧预先组合;骨骼矩阵可由动画系统先算成最终 palette,再供 shader 读取。预计算的判断标准是:输入跨多少顶点复用、跨多少帧复用、数据传输成本是否低于重复计算成本、是否会破坏调试可见性。

第四层是控制 skinning 与 morph 的成本。角色近景需要精细 skinning 和 morph;远景可降低骨骼影响数量、降低 morph channel 数量、使用上一帧或低频更新的动画结果,或者把 skinned 结果写入中间 vertex buffer 供多个 pass 复用。若同一个 skinned mesh 后续参与 depth pre-pass、shadow pass、G-buffer pass 和 motion vector pass,把 skinning 重复放进每个 vertex shader 会产生重复计算。工程上可根据 pass 数量、角色数量和显存预算决定是否使用 compute skinning 缓存结果。

第五层是让实例数据布局贴合访问方式。大量实例渲染时,per-instance transform 和材质索引应连续存放,shader 读取当前 instance 的一小段数据。若每个实例通过复杂 indirection 读取多个分散 buffer,顶点阶段会引入更多缓存未命中和 descriptor 压力。对草、石头、建筑碎片等海量实例,常把 transform 压缩成 position、rotation、scale 或 3x4 matrix,再把风动、颜色变化、材质索引等小数据打包到单独字段。

下面的表把常见优化动作和它们影响的数据路径放在一起,方便从症状回到证据。

症状优先检查可采用的动作主要代价
顶点阶段耗时随远景模型数量上升vertex count、LOD 切换、draw 范围LOD、GPU culling、indirect draw 压缩LOD 生成成本、切换质量
顶点读取带宽过高attribute stride、format、cache 行利用属性量化、拆分冷/热属性、索引重排解码 ALU、精度误差
动画角色在多 pass 中重复耗时pass 数量、skinning 指令、骨骼读取compute skinning、缓存 skinned buffer显存占用、同步和资源状态转换
海量实例吞吐不稳定instance buffer 布局、indirection、batch 粒度instance data 压缩、material sorting、multi-draw管线组织复杂度
法线或切线输出成本高normal matrix、tangent space 构造位置导入阶段预计算、只输出必要空间资产缓存体积

优化还要处理 vertex shader 输出数量。输出 varyings 越多,后续插值和 fragment input 带宽越高。把所有中间值都从 vertex shader 传到 fragment shader 会扩大跨阶段接口。更稳定的做法是只输出 fragment shader 必要的数据,并明确这些数据所处空间。比如只需要纹理采样时输出 UV;需要 world-space 光照时输出 world normal 和 world position;需要 normal map 时输出 TBN 或压缩后的 tangent frame。输出接口的上限和匹配规则在 Vulkan 的 shader interface 中有明确限制,跨 API 迁移时应把这些限制纳入 shader variant 设计。

工具复查时,RenderDoc 适合看 draw call 的 vertex input、shader 反射、buffer 内容和 post-VS 输出;Nsight Graphics 适合进一步看 GPU 时间、warp/wave 层面的 stall 和吞吐;Xcode GPU tools 适合在 Apple 平台观察 Metal pipeline、vertex descriptor、buffer binding 和 tile-based GPU 上的帧成本。工具输出只回答“当前捕获中发生了什么”,结论仍要回到调用次数、输入字节、shader 指令和后续接口四个维度。

22.4 兼容性与不同 API 的实现异同

GLSL、HLSL、MSL 和 SPIR-V 描述的是同一类 vertex stage,但它们把接口绑定、语义命名、坐标约定和编译边界放在不同位置。跨 API 写 vertex shader 时,最稳定的比较维度是入口函数、输入属性绑定、资源绑定、位置输出、用户 varying、矩阵约定、坐标系和中间表示。只比较语法会漏掉真正影响画面的边界。

GLSL 常通过 layout(location = n) 绑定 vertex attributes 和 varyings,通过 uniform block、storage buffer 或 descriptor 语义读取资源。在 OpenGL 中,attribute location 由 shader 内 layout、链接前绑定或自动分配决定;在 Vulkan GLSL 中,location、set、binding 更强调显式化。gl_Position 是预定义位置输出。由于 OpenGL 和 Vulkan 的 clip space、Y 方向和 depth range 约定存在工程差异,移植时要同时检查投影矩阵、viewport、front face 和纹理坐标方向。

HLSL 使用语义表达接口,例如 POSITIONNORMALTEXCOORD0SV_Position。Direct3D 10 及之后的系统值语义使用 SV_ 前缀,SV_Position 由光栅化相关阶段解释;Microsoft Learn 的 HLSL semantics 对 Direct3D 9 语义和 Direct3D 10 之后的 SV_ 语义做了区分。迁移旧 HLSL 时,POSITION0SV_Position 的编译器处理差异会影响语义匹配,尤其在 FXC 与 DXC 的迁移中要显式确认。

struct VSInput {
float3 position : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};

struct VSOutput {
float4 position : SV_Position;
float3 normalWS : TEXCOORD0;
float2 uv : TEXCOORD1;
};

cbuffer FrameData : register(b0) {
float4x4 viewProj;
};

cbuffer ObjectData : register(b1) {
float4x4 model;
float3x3 normalMatrix;
};

VSOutput main(VSInput input) {
VSOutput output;
float4 worldPosition = mul(model, float4(input.position, 1.0));
output.position = mul(viewProj, worldPosition);
output.normalWS = normalize(mul(normalMatrix, input.normal));
output.uv = input.uv;
return output;
}

这段 HLSL 和前面的 GLSL 例子在逻辑上相同,但绑定表达不同。HLSL 的 SV_Position 对应系统值语义,TEXCOORD0TEXCOORD1 在这里只是用户 varying 通道名称。矩阵乘法方向和宿主侧矩阵布局要一起确认:mul(model, vector)mul(vector, model) 表达不同约定,编译器打包规则和 CPU 侧常量缓冲布局也要匹配。

MSL 使用函数属性和参数属性表达 stage 与接口。Vertex 函数通常返回带 [[position]] 成员的结构,输入可以来自 [[stage_in]],资源通过 [[buffer(n)]][[texture(n)]][[sampler(n)]] 绑定。Apple 的 Metal Shading Language Specification 和 JavaScript 依赖的 Apple 文档页面给出的是 Metal 生态的语法与对象规则;工程中应把 MSL shader、MTLVertexDescriptor、render pipeline descriptor 和 buffer binding 放在一起验证。

#include <metal_stdlib>
using namespace metal;

struct VertexInput {
float3 position [[attribute(0)]];
float3 normal [[attribute(1)]];
float2 uv [[attribute(2)]];
};

struct FrameData {
float4x4 viewProj;
};

struct ObjectData {
float4x4 model;
float3x3 normalMatrix;
};

struct VertexOutput {
float4 position [[position]];
float3 normalWS;
float2 uv;
};

vertex VertexOutput mainVS(VertexInput input [[stage_in]],
constant FrameData& frameData [[buffer(1)]],
constant ObjectData& objectData [[buffer(2)]]) {
VertexOutput output;
float4 worldPosition = objectData.model * float4(input.position, 1.0);
output.position = frameData.viewProj * worldPosition;
output.normalWS = normalize(objectData.normalMatrix * input.normal);
output.uv = input.uv;
return output;
}

MSL 例子暴露了 Metal 的两个排查入口。第一是 vertex descriptor 是否把 attribute 0、1、2 映射到正确 buffer、offset、format 和 stride;第二是 [[buffer(n)]] 的编号是否和 command encoder 绑定顺序一致。若位置全为零或属性错位,先检查 vertex descriptor。若矩阵全是旧数据或对象共用同一姿态,先检查 buffer binding 和更新时机。

SPIR-V 是更低层的中间表示。它用 OpEntryPoint 声明入口函数和 stage,用 InputOutput storage class 表达接口变量,用 LocationBindingDescriptorSetBuiltIn 等 decorations 绑定语义。Vulkan 规范要求 vertex shader input variables 通过 LocationComponent 与 pipeline 的 vertex input attributes 匹配;Khronos 的 SPIR-V Specification 则描述了 SPIR-V module、entry point、execution model、storage class 和 decorations。调试 Vulkan shader 兼容性时,最终应看编译后的 SPIR-V 反射结果是否与 pipeline state 匹配。

不同 API 的差异可以压缩成下面这组判断点。

维度GLSL / Vulkan GLSLHLSLMSLSPIR-V
Stage 入口void main() 或指定 entrymain 或编译指定 entryvertex 函数OpEntryPoint Vertex
顶点属性layout(location)语义和 input layout[[attribute(n)]] 与 vertex descriptorLocation decoration
位置输出gl_PositionSV_Position[[position]]BuiltIn Position
资源绑定set/binding 或 OpenGL uniformregister space / register[[buffer(n)]] 等属性DescriptorSet / Binding
用户输出layout(location) out用户语义如 TEXCOORDn返回结构成员Output + Location
排查核心shader location 与 pipeline inputsemantic 与 input layoutvertex descriptor 与 encoder bindingdecorations 与 pipeline state

跨 API 移植 vertex shader 时,先写出一份“接口清单”。清单包含每个 attribute 的名称、format、offset、stride、location 或 semantic、坐标空间、是否按 instance 递增;每个 buffer 的绑定编号、更新频率、内存布局、矩阵主序;每个输出的名称、语义、空间和后续消费者。然后用一个三角形、一个单位矩阵对象、一个只平移实例、一个带非等比缩放的法线测试、一个带骨骼权重的顶点逐步验证。这个顺序能把语法问题、绑定问题、空间问题和数学问题拆开。

最小自检任务

给定一个 draw call:它渲染 500 个实例,每个实例复用同一个 indexed mesh。每个顶点包含 position、normal、UV、joint index、joint weight。每个实例包含一个 4x4 model matrix 和一个 material index。当前画面中,所有实例位置正确,但角色动画局部拉伸,光照在非等比缩放实例上出现明暗翻转,GPU capture 显示 vertex shader 时间在 shadow pass、depth pre-pass 和 G-buffer pass 中重复升高。请判断这个问题应按什么顺序排查,并说明可采用的优化方向。

答案要点

先把问题拆成三个证据链。实例位置正确说明 instance transform 的基本读取路径已经成立,排查重点转向 skinning、normal transform 和多 pass 重复执行。局部拉伸先检查 joint index 是否越界、joint weight 是否归一化、joint matrix 是否处在统一空间、inverse bind matrix 和当前 pose 的组合顺序是否一致;可用单骨骼影响和静态 bind pose 输出逐步验证。非等比缩放下明暗翻转先检查 normal matrix,确认法线使用 model 三阶部分的逆转置并在 fragment shader 使用前重新归一化。多 pass 顶点时间重复升高说明同一 mesh 的 skinning 和属性计算在多个 pass 中重复执行,可比较 compute skinning 缓存、低频动画更新、远景 LOD、减少骨骼影响数量、降低 morph channel 或为 shadow/depth pass 使用精简 vertex shader。最终结论应回到四个维度:输入属性是否匹配、坐标空间是否统一、输出语义是否正确、顶点调用次数和单次调用成本是否受控。

本章知识点总结

  • 阶段角色:Vertex Shader 把资产顶点记录转换成后续管线可裁剪、可光栅化、可插值的数据。
  • 输入接口:Attribute、instance data 和 buffer binding 共同决定 shader 当前调用能读到哪一份顶点和对象数据。
  • 位置输出gl_PositionSV_Position[[position]] 和 SPIR-V BuiltIn Position 都承担裁剪空间位置输出职责。
  • Varying 输出:Vertex Shader 输出的用户属性会经过光栅化插值,成为 fragment shader 的输入。
  • 空间链路:Model、world、view、clip、NDC 和 screen space 要按顺序检查,位置错误通常能沿这条链定位。
  • 法线矩阵:非等比缩放场景下,法线应使用 model 三阶部分的逆转置矩阵并在使用前归一化。
  • Skinning 边界:骨骼动画的稳定性依赖 joint index、joint weight、matrix palette、bind pose 和空间约定一致。
  • Morph 逻辑:Morph target 通常在 model space 中叠加顶点 delta,后续仍走对象变换和投影路径。
  • 实例化路径:Instancing 通过 per-instance 数据复用 mesh,排查重点是 instance step、buffer layout、矩阵主序和绑定编号。
  • 优化入口:Vertex Shader 优化先判断调用次数、attribute 带宽、buffer 读取、算术成本和跨 pass 重复计算。
  • 输出控制:Varying 数量会影响跨阶段接口和插值成本,应只输出 fragment shader 实际需要的数据。
  • API 差异:GLSL、HLSL、MSL 和 SPIR-V 的核心差异集中在输入布局、语义绑定、资源编号、位置输出和坐标约定。