原文路径:http://www.csharpwin.com/csharpspace/3087.shtml
写过Direct3D程序的朋友们可能还记得,在以往,大家常为如何表现更多真实的材质(如玻璃、金属等)而发愁。这种情况在DirectX8.0问世后有所改善了,我们可以编写Shader来完成。最新的Direct3D中,HLSL把程序员从复杂的Shader指令集中解放出来,着力于更重要的算法。
HLSL(High-Level Shader Language)本文将从如下几个部分介绍
首先,什么是Shader&什么是HLSL?
简要地说,Shader就是一种脚本程序,相对独立于D3D主程序,并且被编译成显卡的GPU指令序列在显示芯片上跑。(你肯定想知道更多,比如这种程序用什么来写,都要写什么,怎么让GPU跑这种程序等等,别着急慢慢来),这里有必要先了解一下:
AGP显卡的渲染流程:
首先来根据下面这张图粗略说明一下当前最普遍流行的AGP显卡的渲染流程,甭管是nVidia还是ATI哪一边的。
每次渲染过程(例如,在一帧画面中画一个馒头的过程)都包括顶点处理(Vertex Processing)和像素处理(Pixel Processing)两个主要功能模块的执行。首先,显卡从AGP总线接收这个馒头的顶点数据。这些数据包括位置、法线、贴图坐标(如果是面包可能更需要贴图,也就是说贴图坐标不是必需的)等等,这些都是未经过任何变换,也就是在物体本地空间(Object Space)下的原始坐标。每个顶点依次被送入顶点处理单元,在这里进行坐标变换、光照计算(如果是每顶点光照)等工作,变换的结果是把每个三角形变换置屏幕空间(Screen Space)下直接可用。这里用到的变换矩阵、灯光等信息都是处理每一批顶点时一次性传给显卡的,作为显卡的资源。顶点处理圈定了三角形的范围,接下来就要逐像素地填充这个三角形了。填充哪些像素是靠对顶点屏幕坐标的线性插值来决定的。像素的其他一些必要参数,如颜色,贴图坐标等也是通过对上一步计算出来的顶点的这些属性进行插值得到的。另外每个像素还要通过深度检测和模板检测决定最终是否绘制。需要绘制的像素被送进像素处理模块,进行贴图像素取值,贴图混合等工作,必要的话每像素光照也在这里完成。这里贴图等信息也是作为显卡的资源。像素最终的处理结果被放进后备缓冲。
以往显卡在顶点处理和像素处理过程中执行的是一套布在硬件上的固定的程序,D3D程序员只能设置一些参数,实际上就是你调用IDirect3DDevice::SetRenderState()时做的事,而这样的程序在IDirect3DDevice::DrawPrimitive()中自动执行。那么有些事情就很难办到,如渲染一个水晶馒头。应为参数再多,其渲染所用到的光照公式也跑不出石膏这种东西。现在的显卡,确切的说是现在的Direct3D允许你写这么一段程序替代固定的顶点处理过程和像素处理过程(记住,只是这两个过程,跟插值什么的没关系)。其中替换顶点处理的就叫Vertex Shader(暂时还没有确切的中文名),替换像素处理的就叫Pixel Shader。就是开篇所说的Shader。
这样你应该想到Shader中大概应该写些什么了。如果还不行的话建议复习一下D3D。用什么来写呢?三。GPU自有GPU的指令集,以往的Shader就是用这种汇编式的指令集组成,例如:
vs_1_1
dcl_position v0 dcl_normal v1,r0, v0.x, c0 mad r2, v0.y, c1, r0 |
就如同汇编用多了必然出现C一样,自Direct3D9.0后,一种叫HLSL(High Level Shading Language,高级渲染语言)的面向过程的Shader语言应运而生,本篇将详细介绍的即为此。
HLSL基础
就像每一本编程语言的教材一样,介绍一门语言,首先从它的数据类型,表达式,控制流这些东西说起。HLSL的这些基本语法很像C/C++,不再赘述。有些常见问题还是要说明一下,是为了让你不会被这些牵制了全面了解Shader的脚步。
数据类型
与CPU不同,在显卡芯片中,最小的数据吞吐单元是一个由32位浮点数组成的四元组。这一点很有道理不是,想想你在渲染过程中所有涉及到的数据,最复杂的不外乎四维坐标(x,y,z,w)或颜色(r,g,b,a),这样GPU可以一次性处理一个四元组。而整数什么的在显卡中被放到四元组的一个分量里使用,而很多显卡中,整数、布尔值都不被直接支持,而是转为浮点数使用。至于矩阵,通常用4个四元组表示一个4x4矩阵(默认情况一个四元组存储一行,也可以指定按列存储,属于细节问题,goto:细节问题)其他尺寸的以此类推。
反映到程序上,一个四维向量就被声明为float4,4维方矩阵被声明为float4x4等等。当然,你也可以使用任意不超过4的维度的向量或矩阵,如int3,float3x3,double1。这个double1实际上就是标量了,1可以省略不写。想知道更多,就goto:细节问题
纹理(Texture)&取样器(Sampler)
这俩东西可以看作特殊类型变量。纹理就是Shader中用到的贴图资源,这我想没什么好说的。来解释一下取样器:实际上每张贴图在使用的时候都要用一个取样器。取样器相当于这样一个结构,除了保存贴图本身数据之外,还包括过滤参数等取样信息。通常,读取贴图这样的指令接收的都是取样器类型的参数而并非直接接收纹理贴图。声明及使用纹理或取样器跟使用普通变量一样。这里有一些初始化取样器的方法,还是等到后面的实例中讲述吧。更多内容请goto:细节问题
Semantic & Annotation
任何类型的变量(包括纹理和取样器),我们都可以用Semantic或Annotation修饰来起到一些特殊作用。Semantic暂时翻译成语义;Annotation暂时翻译成注解,这是HLSL中独特的东西。下面这两行中,第一个变量冒号后面的POSITION就是Semantic,第二个变量后面用一对尖括号<>圈起来的表达式就是Annotation,一组<>中可以有很多个表达式。
float3 OmniPos : POSITION; texture TexMap < string name = "test.dds"; >; |
一般来讲,Semantic是告诉应用程序或D3D这个被修饰的变量是做什么用的,Annotation是告诉程序这个变量怎么用。很云山雾罩是吗,是这样,在应用程序代码中,是可以调用D3D的API认出Semantic和Annotation的。例如上面这两行,程序的逻辑就可能是这样:首先写主程序的甲和写Shader的乙约定好POSITION标识该变量代表灯泡A的位置,甲在程序里写:{灯泡A.位置 = XXX; 找到Shader中带POSITION的变量; 给该变量赋值为灯泡A.位置; return;} 那么甲可以不知道乙在Shader中给这个要用灯泡A位置的变量起了什么名,而且乙可以在好几个Shader中给用这个数据的变量起不同的名。然后,甲和乙再约定遇到Annotation中的“name”就将后面的字符串作为文件名建立贴图。于是甲的程序就从Shader中读出了一个文件名,建立了一个贴图以供这个texture变量使用。Semantic和Annotation大概就这么用,首先要约定好各个Semantic和Annotation都是什么意思,这是up to you的,然后就是通过它们的标识来给变量赋值或作其他辅助性工作了。
既然都是做辅助说明的为什么还要分成Semantic和Annotation,我的想法是Semantic简单方便而Annotation能干的事更多。不说这个了,无关大局。要说的是,D3D也跟我们约定了一套Semantic,它们大体上都能顾名思义,详细信息在后面。
控制流
控制流,就是if…else,for,while什么的。在CPU中,这些控制流造成的实际上是指令跳转。但在GPU中指令跳转并不被广泛的支持,以往的大部分显卡只懂得按顺序一句一句执行指令。因此HLSL的编译器可能会做出诸如展开循环、遍历分支等等莽撞的事来适应显卡。所以使用时要特别小心,而且不是所有情况的控制流语句都被支持。具体的很多规则还是在细节问题里。
函数
HLSL中提供了很多函数可供调用,在Direct3D 文档 -> DirectX Graphics -> Reference -> HLSL Shader Reference -> HLSL Intrinsic Functions中有这些函数的详细列表。也可自己写函数用,但是在较早的Shader版本中,就像内联函数一样编译时最终要将函数展开插入到函数调用处。还有一点我想你一定会想到的就是主函数会是什么。Vertex Shader和Pixel Shader各自需要一个主函数,由程序员来指定!没错,程序员在Shader外部指定。具体方法将在下篇讲述(注意不是细节问题)。
// Declare a global uniform variable float4 UniformGlobal; float4 main( uniform float4 UniformParam ) : POSITION { |
细节问题
你会觉得前面说的太过粗略,还有很多问题没有叙述,但相对来讲这些都算是细枝末节了。例如HLSL中保留关键字有哪些;变量的作用域;数据类型的详细信息;四元组分量的使用法则等等,这些在Direct3D文档 -> DirectX Graphics -> Programming Guide -> The Programmable Pipeline -> Programmable HLSL Shaders -> HLSL Language Basics中讲得比我清楚,我也不再多余翻译了。
实例分析
抑或你觉得看到前面的介绍后有些激动,跃跃欲试想接触实际代码,了解Shader的全貌了。那好我们就分析一个例子开始。
我们通过两个典型的Shader来看看怎么用它来实现我们想要的效果。最好先回顾一下前面的渲染流程,再次熟悉一下从Object空间下的顶点数据流到显示器像素的途径。然后不要着急理解下面代码的每一句,仅仅浏览一遍,稍后我会带你分析的。
先是一个Vertex Shader:
///////////////////////////////////////////////////////////////////////////// // Copyright (c) FrontFree_Studio. All rights reserved. ///////////////////////////////////////////////////////////////////////////// /**************** GLOABLE VARIABLES ****************/ float4 MtrlSpec = { 1.0f, 1.0f, 1.0f, 1.0f }; // material’s specular color, white float SpecPow = 8; float3 OmniPos : POSITION = { 0.577, 0.577, -0.577 }; // lights (world space) float4 OmniColor = { 1.0f, 1.0 f, 1.0f, 1.0f }; // light’s color, white float4 AmbLight = { 0.9f, 0.9f, 0.9f, 0.9f }; // ambient light color; float3 CameraPos : CAMERAPOSITION = { 0.0f, 3.0f, 5.0f }; // camera (world space) float4x3 matWorld : WORLD; float4x4 matViewProj : VIEWPROJ; /***** VERTEX SHADER OUTPUT STRUCTURE *********/ struct VS_OUTPUT { float4 Pos : POSITION; float4 Intensity : COLOR0; float4 Spec : COLOR1; float3 Texcoord : TEXCOORD; }; /**************** VERTEX SHADER ****************/ VS_OUTPUT VS( float3 InPos : POSITION, float3 InNormal : NORMAL, float2 InTexcoord : TEXCOORD ) { VS_OUTPUT Out = (VS_OUTPUT)0; //Calculate the output color by per-pixel lighting // first, calculate the diffuse component float3 P_World = mul(float4(InPos, 1), matWorld); // position to world space float3 ToLight = normalize( P_World – OmniPos ); float3 Normal = normalize( InNormal ); float4 Diff = dot( ToLight, Normal ) * OmniColor; Out.Intensity = Diff + AmbLight; // then, the specular component float3 Reflection = reflection( -ToLight, Normal ); float3 ToEye = normalize( CameraPos – P_World ); float4 Spec = pow( dot( Reflection, ToEye ), SpecPow ) * MtrlSpec * OmniColor; Out.Spec = Spec; // Output the position, texture coordination Out.Pos = mul(float4(P_World, 1), matViewProj); Out.Texcoord = InTexcoord; return Out; } |
下面是个Pixel Shader:
/////////////////////////////////////////////////////////////////////////////
// Copyright (c) FrontFree_Studio. All rights reserved. ///////////////////////////////////////////////////////////////////////////// /**************** TEXTURES ****************/ texture TexMap < string name = "test.dds"; >; sampler2D TexSampler = sampler_state { Texture = <EnviMap>; MipFilter = LINEAR; MinFilter = LINEAR; MagFilter = LINEAR; }; /**************** PIXEL SHADER ****************/ float4 PS ( VS_OUTPUT In ) : COLOR { float4 Tex = texture2D( TexSampler, In.Texcoord ); return Tex * In.Intensity + In.Spec; } |
这是一个简单且无聊的例子,说它无聊是因为没有任何特效,仅仅是有高光,外加一重纹理贴图。如果不用Shader实现,仅需在D3D默认效果上多加一行代码。这个例子只是为了告诉你Shader里都需要干什么。首先是在Vertex Shader中将物体坐标转换至屏幕坐标;同时进行光照计算出顶点的亮度,这里用的是Phone模型。然后在Pixel Shader中进行纹理取样,再混合上亮度得到最终结果。高光是在Vertex Shader中计算出来并在Pixel Shader中加上去的。
光照模型 说通俗一点,光照模型就是指采用何种方式来根据光源方向、顶点(或像素)位置、法线等信息计算该顶点(或像素) 明暗信息。上面的Shader中采用的是最常见的每顶点Phone模型。即每个顶点的明暗信息由环境光(Ambient)+漫反射光 (Diffuse)+高光(Specular)组成。通常,灯光打在物体上是通过漫反射进入眼睛,因此不管你从哪个方向看一个馒头, 上面的大部分明暗只取决于背光还是向光。数学上就是取决于该顶点向光的方向(图中的向量L)和其法线之间的夹角。线数中两根标准化的 向量点乘结果就是其夹角的最好度量。因此程序里也就这么算。高光(准确讲该叫镜面反射光)就不一样了。你应该见过这样的情节 :张某疾步冲进绿柳庄,突然眼前金光一闪,“哦,倚天剑”……实际上倚天剑和阳光的位置并没有改变,张某是否看见高光 还要取决于他视线方向和光线反射方向的关系。这儿有个比较复杂的数学公式来计算高光分量,相信读者应该能把他找出来。至于 环境光,实际的场景中光线是非常复杂的,即使没有光源直接照射在物体上,它也将受到周围物体漫射出来的光线照亮。Phone 模型中,假设环境光是从四面八方来的强度一样的光,因此环境光照分量基本上是不取决于任何方向向量的常量。最后,各个分量 是加在一起最终决定明暗的。 上面只讨论光照强度,记得美术老师讲过,物体的颜色取决于光源本身的颜色,光照强度和物体材质的颜色。然后D3D老师讲过,只要把那三个东西乘到一起就得到了最终的颜色。这里的乘是指各个颜色分量(红绿蓝)分别相乘,实际上这是产生颜色过滤的方法。比如说,一个颜色值为(r,g,b,a)的颜色乘以50%灰(0.5,0.5,0.5,0.5)的结果就是(0.5r, 0.5g,0.5b,0.5a),亮度减了一半,相当于带了个50%灰的眼镜看。 由于本篇着重介绍的是HLSL语言,所以对于光照模型,笔者不打算做过多说明,将放到以后的文章中讲述。 |
好了,来看代码。每个Vertex Shader都要以顶点数据流中的数据作为其入口参数。但你头脑里清楚哪个参数是位置,哪个是法线,哪个是贴图坐标,你完全可以把法线叫m_Position,叫abcd,叫……D3D如何知道把数据流中的法线信息往哪儿放呢?好,Semantic。D3D与我们约定了一套Semantic:其中POSITION是位置,NORMAL、TANGENT、BINOMAL分别是法线、切线、副法线,TEXTURE0-7表示8套贴图坐标。
切线是指顶点的切线向量,副法线是与法线和切线正交的向量,都是预计算好并存在顶点数据里的。常用于凸凹贴图技术。 |
Vertex Shader的输出数据,也就是返回值可以包含很多信息,但是必须有一个float4是表示位置的。因为D3D认为,你可以在顶点处理过程中不作任何光照处理,但是必须要把顶点转换至正确的屏幕位置。除了位置以外,其他都将向下传入Pixel Shader作为其入口参数。这些参数也用Semantic标识。
搞清楚一点很重要,当每个顶点的颜色被计算出来之后,像素的颜色将由它所在的三角形的三个顶点插值得到。还记得Direct3D Tutorials2吗? |
你仅仅指定了这个三角形顶点的颜色,而中间那些好看的渐变效果实际上是硬件自己对顶点颜色进行插值得到每个像素的颜色。当我们使用光照的时候,只不过是用光照计算出的顶点颜色代替了你手动指定的颜色。贴图坐标也是这样进行插值的。 |
Pixel Shader所接收的Semantic是COLOR0-7(颜色),TEXCOORD0-7(贴图坐标)。这里用个结构体来放置所有的返回值,以及Vertex Shader的函数原形一并如下所示:
struct VS_OUTPUT
{ float4 Pos : POSITION; float4 Intensity : COLOR0; float4 Spec : COLOR1; float3 Texcoord : TEXCOORD; }; VS_OUTPUT VS( float3 InPos : POSITION, float3 InNormal : NORMAL, float2 InTexcoord : TEXCOORD ) |
Shader中的处理过程无非也就是计算出四个返回值分量。
首先位置(Pos)很好说,就是将顶点的原始位置(InPos)乘上变换矩阵。
float4x3 matWorld : WORLD;
float4x4 matViewProj : VIEWPROJ; … float3 P_World = mul(float4(InPos, 1), matWorld); // position to world space Out.Pos = mul(float4(P_World, 1), matViewProj); |
你看到这两个全局变量被Semantic标志为WORLD和VIEWPROJ,程序中将通过它们把当前的世界变换矩阵,视矩阵和投影矩阵的乘积传进来。
这里有个优化的原则。记住Vertex Shader是每处理一个顶点运行一遍,如果某些信息对所有顶点(指同一批顶点流即同一物体中,使用同一个Shader的所有顶点)来说计算结果都一样,那最好把它在程序中计算出来直接传给Shader。例如三个变换矩阵的乘积。本例中,由于还需要把顶点位置变换置世界空间作他用,因此没要求程序把矩阵都乘起来。 同理,每顶点能够计算出来的东西就不要放在Pixel Shader中让每像素都重复一遍。 |
将顶点乘以矩阵用到了HLSL的内置指令mul(),实现矩阵乘法。不牵扯到投影的三维坐标变换并不需要第四维w坐标,而且在这些变来变去的过程中w会始终保持为1。因此程序传进来的顶点数据也是没有w的。四维向量才被允许乘以4x4矩阵,因此我们还要把InPos和P_World补上w坐标,即float4(InPos, 1)。
然后我们把每个顶点照亮赋给Color。这几个全局变量就是光照要用到的一些材质颜色,灯光等信息。我想应该能让你看明白都是干什么的了。SpecPow是用于计算高光的,越大,反光面积越小。其余的我想也不用多解释了吧。
float4 MtrlSpec = { 1.0f, 1.0f, 1.0f, 1.0f }; // material’s specular color, white float SpecPow = 8; float3 OmniPos : POSITION = { 0.577, 0.577, -0.577 }; // lights (world space) float4 OmniColor = { 1.0f, 1.0 f, 1.0f, 1.0f }; // light’s color, white float4 AmbLight = { 0.9f, 0.9f, 0.9f, 0.9f }; // ambient light color; // camera (world space) float3 CameraPos : CAMERAPOSITION = { 0.0f, 3.0f, 5.0f }; … float3 ToLight = normalize( P_World – OmniPos ); float3 Normal = normalize( InNormal ); float4 Diff = dot( ToLight, Normal ) * OmniColor; Out.Intensity = Diff + AmbLight; // then, the specular component float3 Reflection = reflection( -ToLight, Normal ); float3 ToEye = normalize( CameraPos – P_World ); float4 Spec = pow( dot( Reflection, ToEye ), SpecPow ) * MtrlSpec * OmniColor; Out.Spec = Spec; |
至于贴图坐标,直接把它拷贝到返回值就行了。
Out.Texcoord = InTexcoord; |
进入Pixel Shader,所有入口参数由Vertex Shader中的计算结果插值得到。我们只不过简单的用tex2D()进行了一下贴图取样,然后与光照强度混合得到最终结果。贴图的颜色其实才是美术老师说的“物体材质的颜色”。
float4 PS ( VS_OUTPUT In ) : COLOR { float4 Tex = texture2D( TexSampler, In.Texcoord ); return Tex * In.Intensity + In.Spec; } |
总结
笔者已经进自己可能给大家讲清楚了HLSL的一些基础知识。从它的优点作用,到基本概念,到渲染流程,最后用个例子给大家串了一遍。
这只是上篇,下一次,我们将看到:
l Shader是如何与你的程序进行交互的
l Shader使用的极致——Effect File
l 介绍多种编辑、编译、调试Shader的工具
l 等等
好,文章中如有疏漏,恳请大家指出,非常欢迎各位与我交流。我的邮箱是xuantz@frontfree.net,msn是phyligefox@hotmail.com
参考文献:
《Vertex Shader 结构》 (http://www.gameres.com/Articles/Program/Visual/3D/VertexShader.htm)
《Introduction to the DirectX 9 High-Level Shader Language》Craig Peeper Microsoft Corporation, Jason L. Mitchell ATI Research (http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnhlsl/html/shaderx2_introductionto.asp)
《ATI™ Radeon X800 3D Architecture white paper》