图形管线与Shader的交互
入口函数与非入口函数
入口函数是Shader的主函数。来看这样一段程序
float4x4 wvpMat;
struct VS_INPUT{
float4 pos: SV_Position;
float4 tex: SV_Texcoord0;
};
struct VS_OUTPUT{
float4 pos: SV_Position;
float4 tex: SV_Texcoord0;
};
float4 world_pos( float4 p ){
return mul(p, wvpMat);
}
VS_OUTPUT vs_main(VS_INPUT in){
VS_OUTPUT o;
o.pos = world_pos(in.pos);
o.tex = in.tex;
return o;
}
很显然,vs_main是一个合法的VS程序的主函数,那么我们称vs_main为入口函数,称world_pos为非入口函数。Shading language的入口函数,其实和C语言的主在概念上没有什么区别。但是在SASL中,我们要求一个入口函数它所有的输入和输出都要正确的关联到语义上。SM4中这一条件被放宽了,入口函数也可以提供无语义的uniform参数。
语义分类
对于Shading Language而言,最重要的两个操作是从图形管线中获取数据并将数据写回到管线中。流水线中的数据是附带了语义信息的,用于表达这个数据的用途。例如SV_Position就指明了这样一个数据是表示位置的。用户输入的数据、SL输出的数据,都是依靠语义信息来确保读取和写入的正确性。例如SV_Position只能从某个顶点流的特定偏移量获取,SV_Color的数据才能被写到color buffer中。
SASL支持的语义集合是HLSL Shader Model 4.0的子集。目前参考的HLSL版本为4.0。
在Shader Model 4.0的所有输入语义中,一些语义的值直接来自于外部存储,例如SV_Position的数据来自顶点流,一些语义的值则是来自于管线执行中间计算的结果。输出语义也是如此。
Shader从设计之初便需要应对每秒百万到数亿的调用,因此一些平常不可见的开销问题在这里也变得尤为显著,例如函数参数压栈的开销。所以将所有输入数据均按值或者按地址传递到入口函数中是不妥的。为了尽可能的减少内存读写的次数,从外部存储读入(例如Vertex Buffer)或者写入的外部存储(例如Stream Output或者Frame Buffer)的数据,我们一律以指针+偏移的形式将数据传递到Shader中,称之为Stream类型,而临时的语义变量,如SV_IsFrontFace,我们则暂存到一个临时的buffer中,称之为buffer类型。
在SASL中我们将shader的全部语义分为四类,Stream_in,stream_out,buffer_in,buffer_out。
Shader还有一种特有的存储类型,uniform。这一类型在编译期的时候是一个变量,在代码生成期/优化期是一个常量。如果将这一类型的量按照编译期常量来处理,那么便能获得更高的运行时性能,比方说一些条件展开可以通过优化而被消除。但是,这也意味着一旦uniform量发生变化后,shader便最少需要重新执行代码生成乃至于重新编译。这将会带来巨大的性能开销。由于SASL主要执行在CPU上,CPU对于动态代码的执行优化要远远优于GPU,例如间接地址读取指令和分支预测。因此我们将uniform作为一个普通的变量经由buffer_in来执行输入,以平衡代码调用和编译之间的开销。
数据结构与入口签名
SASL最终将生成如下的签名:
struct stream_in{
float4* pos;
float4* tex;
};
struct buffer_in{ float4x4 wvpMat; };
struct stream_out{}; // empty.
struct buffer_out{
float4 pos;
float4 tex;
};
float4 world_pos( float4 pos, buffer_in* bi );
void vs_main( stream_in* si, buffer_in* bi, stream_out* so, buffer_out* bo );
通过对语义和常量进行重整,SASL减少了不必要的拷贝开销。
结构体的语义布局与常规布局
我们注意到,VS_OUTPUT对于返回值和堆栈变量的类型时的意义是不同的。在返回值时,它匹配了语义输出,而在堆栈变量时,它只是一个普通结构体的内存布局。这就要求,VS_OUTPUT在分析时必须同时产生并保存两套内存布局信息。
但是实际上由于布局差异仅仅在入口函数才存在,并且只有当结构体作为入口函数参数或返回值的时候才会使用语义布局,其他函数内无论是参数还是变量都是使用普通布局,因此我们运用一个临时对象,将语义布局的值拷贝成一个普通布局的对象。也就是说,入口函数内的代码中所有对这个参数值的读取实际上都是对临时对象的读取。其代码类似于下段:
void vs_main( stream_in* si, buffer_in* bi, stream_out* so, buffer_out* bo ){
// initialization
VS_INPUT __tmp_in = {*si->pos, *si->tex};
VS_OUTPUT __tmp_out;
// end initialization
VS_OUTPUT o;
o.pos = world_pos( __tmp_in.pos, bi );
o.tex = __tmp_in.tex;
__tmp_out = o;
// return
bo->pos = __tmp_out.pos;
bo->tex = __tmp_out.tex;
return;
// end return
}
那么通过临时对象的构造,便可以将其余部分的代码通过常规布局生成,避免了在普通布局和语义布局之间复杂的判断和逻辑。尽管临时变量的使用导致了代码在外观上看起来很低效,但是实际上这种极为简单的冗余代码,是非常适合LLVM这种基于SSA的优化方案的。