本文版权归我所有,仅供个人学习使用,请勿转载,勿用于任何商业用途。
由于本人水平有限,难免出错,欢迎大家和我交流。
作者:clayman
Blog:http://blog.csdn.net/soilwork
clayman_joe@yahoo.com.cn
三.绘制三角形
创建几何体信息
讲了那么多,还没绘制过任何图形呢,三角形是3D图形中最基本的图元之一,就从绘制三角形开始吧。
首先,需要一些数据来描述三角形。XNA中使用顶点来描述物体的几何信息。顶点中通常包含顶点位置,颜色,纹理坐标,法线,切线等信息。XNA中有很多现成的结构来储存常见顶点类型,比如:VertexPositionColor, VertexPositionNormalTexture,VertexPositionTexture等结构。你也可以定义新的顶点类型。 目前,简单的VertexPositionColor类型就能满足需要,正如它的名字所示,这种顶点格式包含顶点的位置和颜色信息。位置是包含三个分量的矢量,定义顶点在3D空间中的坐标。XNA有自己的颜色系统,所以不需要使用System.Draw中的Color类。以下是定义三角形的代码,把它放到一个名为InitTrangile()的函数中:
private void InitTrangile()
{
verts = new VertexPositionColor[3];
verts[0].Position = new Vector3(0, 0.5f , 1.0f );
verts[0].Color = xna.Color.Red;
verts[2].Position = new Vector3( -0.5f , -0.5f , 1.0f );
verts[2].Color = xna.Color.Green;
verts[1].Position = new Vector3( 0.5f , -0.5f , 1.0f );
verts[1].Color = xna.Color.Blue;
}
接下来,需要创建一个称为VertexDeclaration的对象。从名字就能看出,这个对象是用来描述顶点类型的。注意,无论绘制什么图形时,都必须先使用这个对象告诉GraphicsDevice所处理的顶点是什么类型。对于XNA内置的顶点类型来说,创建相应的VertexDeclaration对象也非常简单。首先在Form1中添加VertexDeclaration成员decl,然后在InitTrangile()中初始化变量:
decl = new VertexDeclaration(this.graphicsDevice, VertexPositionColor.VertexElements);
自定义顶点类型:
当XNA中的预定义顶点类型不能满足要求时,可以手动创建结构来保存顶点数据。假设我们希望顶点中包含位置,纹理坐标,法线和切线信息,把这个结构称为VertexPosTexNorTan,它只是一个简单的数据集合:
struct VertexPosTexNorTan
{
public Vector3 Position;
public Vector3 Normal;
public Vector2 UV;
public Vector3 Tangent;
}
对于创建VertexDeclaration来说就稍微麻烦一点,它的构造函数需要一个VertexElement类型的数组,需要先构造出这个数组:
VertexElement[] element = new VertexElement[]
{
new VertexElement(0,0,VertexElementFormat.Vector3,VertexElementMethod.Default,VertexElementUsage.Position,0),
new VertexElement(0,12,VertexElementFormat.Vector3,VertexElementMethod.Default,VertexElementUsage.Normal,0),
new VertexElement(0,24,VertexElementFormat.Vector2,VertexElementMethod.Default,VertexElementUsage.TextureCoordinate,0),
new VertexElement(0,32,VertexElementFormat.Vector3,VertexElementMethod.Default,VertexElementUsage.Tangent,0),
};
VertexElement里每个元素对应VertexPosTexNorTan中的一个成员。第一个参数表示关联到顶点分量的数据流索引。大多数情况下一条数据流就可以了,索引0表示第一条流。第二个参数是成员在顶点结构中的偏移值。Positon是结构中的第一个元素,因此偏移为0。Position是Vector3类型的变量,所以对第二个成员Normal来说,它在结构中的偏移值为12。这里的偏移值都以Byte为单位。下一个参数是VertexElementFormat类型的枚举,用来表示每个成员的数据类型。接下来的VertexElementMethod枚举表示在图形流水线中的图元镶嵌光栅化阶段如何对当前变量进行插值,一般使用默认值就可以。VertexElementUsage是一个比较重要的参数,指定当前成员的用途。比如,定义Position变量在程序中表示顶点的位置。最后一个变量也和VertexElementUsage枚举有关。有时,一个顶点可能会有一个以上的同用途元素,比如,有两组纹理坐标,为了区分两组不同的纹理坐标,必须指定一个索引值。这里,并没有重复用途的元素,所用使用0表示它是该用途的第一组数据就可以。
有了VertexElement数据,创建VertexPosTexNorTan顶点相应的VertexDeclaration就很简单了:
decl = new VertexDeclaration(graphicsDevice, element);
编写HLSL代码
有了顶点信息,接下来要做的就是渲染这个三角形。显然,渲染任务是由GPU来完成的,所以接下来的代码将使用HLSL来编写。为项目添加一个文本文件,把文件名改为simpleTriangle.fx,在文件中键入以下代码:
void transform(inout float4 pos :POSITION,
inout half4 color : COLOR0)
{
pos = pos;
color = color;
}
void coloring(inout half4 color :COLOR)
{
color = color;
}
technique render
{
pass P0
{
vertexShader = compile vs_2_0 transform();
pixelShader = compile ps_2_0 coloring();
}
}
这可以说是最简单的shader了,来看看这些代码是什么意思(关于HLSL的详细语法,已经超出了本文讨论范围,可以参考我的其他文章)。首先,关键字technique定义了一个完整的渲染过程。一个fx文件中可能有一个或者多个technique,渲染时,必须指定使用哪一个technique。目前只有一个名为render的technique。Pass关键字定义了一个渲染遍。一个完整的渲染过程中,可以包含多个渲染遍。注意,使用的pass越多,所要的绘图代价也越大。P0中又定义了vertesShader和pixelShader。
让我们打个比方来解释这些概念吧。假如我们需要绘制一副画,那么整个绘制过程就是一个technique。绘画时,我们可能先用铅笔打草稿,然后上色,最后完善,其中每个步骤对应为一个pass。每个步骤中具体如何绘制物体,就由vertesShader和pixelShader来定义。你可能对P0中的两行代码还有些迷惑,让我用为你当一次翻译吧,第一行代码表示:使用vs_2_0配置条件,把tranform()函数编译为vertexShader。后一行代码与此类似。在绘图时,编译好的vertesShader和pixelShader程序将由XNA分别加载到GPU硬件的vertesShader和pixelShader中运行(有些像绕口令-_-b)。
接下来看被编译为vertesShader的transform函数。它把顶点的位置和颜色作为参数,同样再把位置和颜色作为返回值。因为是最简单的shader,所以我们并没有为顶点和颜色进行任何计算,就原样输出了。对于聪明的你来说,相信已经能看懂后面的coloring函数了。
接下来,我要加强你对图形流水线的认识。上面的代码中,你看到对transform或coloring的方法调用吗?看到为他们传递参数吗?显然没有,那么程序是如何运行的呢。当顶点数据提交到GPU时,顶点结构中的数据将被分别输送到不同的寄存器中,比如把顶点位置放到一个寄存器,颜色信息放到另一个寄存器。GPU对每个顶点调用vertexShader进行处理。那么vertexShader如何知道去那个寄存器找相应的值作为输入参数呢?前面在自定义顶点中曾讲过,VertexElement保存了顶点数据中每个值的用法VertexElementUsage和相应的索引。此外,再次看tranform函数的声明部分:
inout float4 pos :POSITION
这里的语法有些奇怪,不是吗?冒号前面,我们声明了一个float4类型的pos变量,它既是输入参数,也是函数返回值,而冒号以及POSITION则定义这个变量用来表示位置信息。发现它和VertexElementUsage的作用有些相似了没。shader正是通过这个标记来找到相应的寄存器,获得正确输入。
在VertexShader处理完成之后,到了流水线的图元装配(镶嵌)和光栅化阶段。这个过程是不可编程的,因此不需要编写代码。但它究竟完成哪些任务呢?目前为止,我们只有三角形的三个顶点,因此这里必须计算出三角形究竟将覆盖平面中的哪些像素,并通过插值计算,得出每个像素可能包含的属性值,比如纹理坐标,颜色,法线等等。处理完成过后,每个值又放到相应的积存器中。
接下来,GPU为每个像素调用PixelSader,同样用前面描述的方法来寻找输入值,并且完成处理过程。
哦,相当复杂的一个过程,不是吗?不知道我讲述的是否清楚,如果你现在没有完全理解,也不要担心,后面还会继续对此进行解释。
初识Content Pipeline
HLSL代码已经编写好了,现在需要把它加载到程序中。第一章中曾说过.fx文件,纹理,模型这些都属于游戏资源,自然需要使用Content Pipeline来处理它们。由于我们建的是普通winForm应用程序,现在解决方案中还不包含Conten Pipeline。好了,现在关闭整个项目,用记事本打开WindowsApplication1.csproj文件,可以看到这是一个类似xml的文件,把以下代码添加到<PropertyGroup>元素之内:
<ProjectTypeGuids>{ 9F 340DF3-2AED-4330-AC16 -78AC 2D9B4738};{FAE04EC0 -301F -11D3-BF4B -00C 04F 79EFBC}</ProjectTypeGuids>
<XnaFrameworkVersion>v1.0</XnaFrameworkVersion>
<XnaPlatform>Windows</XnaPlatform>
<XNAGlobalContentPipelineAssemblies>Microsoft.Xna.Framework.Content.Pipeline.EffectImporter.dll;Microsoft.Xna.Framework.Content.Pipeline.FBXImporter.dll;Mi crosoft.Xna.Framework.Content.Pipeline.TextureImporter.dll;Microsoft.Xna.Framework.Content.Pipeline.XImporter.dll</XNAGlobalContentPipelineAssemblies>
<XNAProjectContentPipelineAssemblies>
</XNAProjectContentPipelineAssemblies>
再次打开项目,在solution explorer窗口中双击properties,可以看到项目属性标签中已经多了content pipeline标签,content pipeline已经加载到解决方案中了。选中effect.fx文件,在properties窗口中可以看到这个文件已经不再是简单添加到项目中的文件,而是经过content pipeline处理并导入项目中的。前一章说过,content pipeline分为预处理和运行时处理两个阶段,目前为止,我们完成了预处理阶段,要在运行时使用content pipeline,就必须通过ContenManager对象了。查看文档,可以知道ContentManager的构造函数需要一个IServiceProvider类型的接口作为参数。更确切的说,ContentManager需要IServiceProvider提供一个IGraphicsDeviceService接口来初始化对象。IServiceProvider是定义在System名称空间下的一个接口,XNA中,GameServiceContainer对象继承了这个接口,所以下面我们将使用GameServiceContainer而不是IServiceProvider。
开始编码,首先,让Form1继承IGraphicsDeviceService接口:
partial class Form1:IGraphicsDeviceService
这里,只需要实现接口的GraphicsDevice属性:
public event EventHandler DeviceCreated;
public event EventHandler DeviceDisposing;
public event EventHandler DeviceReset;
public event EventHandler DeviceResetting;
public GraphicsDevice GraphicsDevice { get { return graphicsDevice;} }
接下来在Form1中添加GameServiceContainer和ContentManager成员:
private ContentManager content;
private GameServiceContainer service;
在Initiaize()方法中添加对象初始化代码:
public void Initiaize()
{
service = new GameServiceContainer();
service.AddService(typeof(IGraphicsDeviceService),this);
content = new ContentManager(service);
}
使用ContentManager对象就能把任何所需要的资源加载到程序中了。
使用Effect绘制图形
讲了那么多,终于到了最终的绘图部分。我们已经有了三角形数据,渲染三角形的HLSL代码,如何把它们联系起来,并且进行绘图呢?前面说过,Shader将由XNA调用编译并加载到GPU中,而实际上,这些任务都是由Effect类来完成的,这是一个功能非常强大的类。在Form1中添加Effect成员:
private Effect effect;
你可能正在迷惑,刚才花了那么多代码创建ContentManager对象,却没有使用它。现在就是它派上用场的时候了。Effect类虽然提供了众多构造函数,但最常见的方法是直接通过一个.fx文件创建Effect对象,在Initiaize()中添加如下代码:
effect = content.Load<Effect>("simpleTriangle");
effect.CurrentTechnique = effect.Techniques["render"];
第一行代码是用content pipeline加载资源的标准方法。注意,这里所加载的是经过Content Pipeline预处理过的文件,因此不需要带任何后缀名,但要保证文件名路径的正确。第二行代码则是为effect指定一个technique用于渲染,前面说过,一个.fx文件中可能有多个technique。
现在可以绘图了:
protected override void OnPaint(PaintEventArgs e)
{
graphicsDevice.Clear(ClearOptions.Target, Microsoft.Xna.Framework.Graphics.Color.CornflowerBlue, 1.0f , 0);
graphicsDevice.VertexDeclaration = decl;
effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
pass.Begin();
graphicsDevice.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.TriangleList, verts, 0, 1);
pass.End();
}
effect.End();
graphicsDevice.Present();
}
首先使用decl变量告诉graphicsDevice所要绘制的顶点类型。使用effect绘图时,必须前调用Begin()方法,它将激活当前的technique,与此像对应,必须在绘图结束之后调用End()方法。之后,迭代当前technique中的所有pass进行绘图,同样必须为每个pass调用Begin()和End()方法。注意,对于有Managed DirectX经验的人来说,可能发现graphicsDevice缺少了BeginScene和EndScene方法,XNA中已经删除了这两个方法,自然也不用调用它们。
DrawUserPrimitives是真正发出绘图命令的地方,它把顶点提交给GPU,然后GPU开始在流水线上处理传入的顶点。DrawUserPrimitives的第一个参数是所要绘制的图元类型,它是PrimitiveType类型的枚举。在后面的章节将详细讨论它。接下来是包含顶点数据的数组。第三个参数是所要绘制的顶点在数组中的偏移值。比如顶点数组中包含10个顶点,而我们只希望绘制最后的三个顶点,那么就必须用这个参数来控制。最后一个参数则是所要绘制的图形数量。
现在运行程序,可以看到如下画面(注意我稍微修改了程序结构,详见源代码):
哦,我们所绘制的第一个图形程序看起来还不错。注意到没有,对于顶点以外的像素,硬件进行了插值,渲染出了一个多彩的三角形。如果你把simpleTriangle.fx文件中Coloring()函数中的代码改为:
Color = half4(1.0,1.0,1.0,1.0);
那么将看到一个纯白色的三角形。此外,打开编译好的程序目录,可以找到一个名为simpleTriangle.xnb的文件,它就是经过Content Pipeling预处理的simpleTriangle.fx文件,也是程序运行时用ContentManager所加载的文件。
Patch,Patch,完善程序
刚才看到的程序仅仅是可以运行而已,实际上还有很多潜在的问题。接下来,我就带你逐个发现,并消灭它们。
首先,缩放一下窗口大小看看,嗯嗯,看起来缩放之后,有时并不会(及时)更新显示。这是因为在有时在缩放之后,有时windows并不认为当前的显示区域无效了,而重新调用OnPaint方法绘图。解决这个问题,需要在graphicsDevice.Present();之后添加以下代码:
this.Invalidate();
它将告诉Windows总是重新绘图。
再次运行程序,这时可能发生几种情况:1如果你运气足够好,程序将照常运行;2,看到窗口一片空白-_-b;3,看到窗口在不停闪烁。这是因为在windows在Invalidate()方法之后,会“智能”的重新绘制窗口。解决这个问题,可以在InitializeComponent()添加如下代码:
this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.Opaque, true);
它将告诉windows所有的绘图任务都由OnPaint方法来完成,并且不需要窗口透明效果。
再运行程序,可以正确显示三角形了。不要高兴的太早,最大化窗口,你将看到我们可爱的三角形变的有些惨不忍睹,边缘充满了锯齿,并且变得有些模糊。这是因为再缩放之后没有为GraphicsDevice更新后备缓冲的大小,也就是说图形设备仍然以程序初始化时的分辨率绘图,只不过是把图形放大了,以适应新的窗口大小。解决这个事件可以订阅当前窗口的Resize事件,在InitializeComponent()中添加以下代码:
this.Resize += new EventHandler(OnResize);
接下来添加事件处理程序:
void OnResize(object sender, EventArgs e)
{
PresentationParameters presentParsms = new PresentationParameters();
presentParsms.IsFullScreen = false;
presentParsms.SwapEffect = SwapEffect.Discard;
presentParsms.BackBufferHeight = this.ClientSize.Height;
presentParsms.BackBufferWidth = this.ClientSize.Width;
graphicsDevice.Reset(presentParsms);
}
好了,主要的问题都处理完了。当然还有一些潜在的问题,不过对于我们的第一个绘图程序来说可以暂时忽略它们了。
四.小节
本章介绍了GraphcsDevice类,它是整个XNA中最重要的类型之一,几乎所有绘图任务,与图形相关的资源,对象都将使用到它。此外,我们完全从零开始,创建了一个XNA程序,学习了如何使用XNA来绘图,以及大量重要的基础知识。这对于后面的学习是非常重要的,让你了解了如何在不使用Game类的情况把XNA集成到普通winForm程序或关卡编辑器中。 最后,使用可编程渲染管道,编写了第一个Shader,绘制了一个可爱的三角形。
~~~~~~第二章完~~~~~~~~~