赞助广告

 

年份

最新评论

评论 RSS

在场景中添加光线——给光线添加更高的细节:逐像素光照

clock 二月 14, 2011 14:31 by author alex
问题 在前两个教程中,是在每个顶点中计算明暗程度,对三角形的每个像素需要对这个值进行插值。所以这叫做逐顶点光照(per-vertex lighting,又叫做高洛德着色,高洛德浓淡Gouraud shading)。 在某些情况中,逐顶点光照没不能产生最好的结果。特别是使用大三角形或有锐利的边缘或两者都有时,往往得不到想要的结果。 举例说明,如图左边是一个有三个面的立方体。图的右边表示共享的法线应该如何定义。本例中,光线方向用四根箭头表示。 图6-5 Vertex shader对per-pixel lighting 关注立方体的顶部,对应右图中顶点2和4之间的线段。使用逐顶点光照,会计算顶点2和4的明暗。在顶点4光照不多,因为顶点4的法线几乎是垂直于光线方向的,这里我们认为是20%的光照。顶点2光照得多,因为它的法线方向几乎和光线方向一致,我们设为80%光照。在逐顶点光照中,三角形中的像素的明暗要进行插值,所有介于这两个顶点之间的像素接受的光照是20%至80%之间的插值。这样,没有一个像素可以获得100%的光照。 但是,顶点2和4之间的某一个像素的顶点方向与光线方向完全一致!这个法线显示在图6-5的右图中。很明显,这个像素应该获得100%的光照,但是使用逐顶点光照,这个像素只能获得介于20%和80%之间的某个光照值。 解决方案 逐顶点光照只计算顶点的精确明暗,而在顶点间的像素的明暗是通过插值获取的。 使用逐像素光照,你对所有像素的法线进行插值,让你可以计算每个像素的精确明暗。 工作原理 使用BasicEffect,很容易使用逐像素光照。在设置BasicEffect参数时,只需添加以下代码行: basicEffect.PreferPerPixelLighting = true; 注意:要使逐像素shader可以工作,你必须要拥有支持Shader 2.0以上的显卡。你可以使用以下代码检查显卡的支持: GraphicsDevice. GraphicsDeviceCapabilities.MaxPixelShaderProfile>=ShaderProfile.PS_2_0 代码 下面的代码创建如图6-5左图所示的顶点。因为某些法线可能不再是单位长度,所有确保在最后要对它们进行归一化: private void InitVertices() { vertices = new VertexPositionNormalTexture[8]; vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), new Vector3(-1, 0, 0), new Vector2(0, 1)); vertices[1] = new VertexPositionNormalTexture(new Vector3(0, -1,- 1), new Vector3(-1, 0, 0), new Vector2(0, 0)); vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(-1, 1, 0), new Vector2(0.33f, 1)); vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0,- 1), new Vector3(-1, 1, 0), new Vector2(0.33f, 0)); vertices[4] = new VertexPositionNormalTexture(new Vector3(1, 0, 0), new Vector3(1, 1, 0), new Vector2(0.66f, 1)); vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0,- 1), new Vector3(1, 1, 0), new Vector2(0.66f, 0)); vertices[6] = new VertexPositionNormalTexture(new Vector3(1, -1, 0), new Vector3(1, 0, 0), new Vector2(1, 1)); vertices[7] = new VertexPositionNormalTexture(new Vector3(1, -1,- 1), new Vector3(1, 0, 0), new Vector2(1, 0)); for (int i = 0; i < vertices.Length; i++) vertices[i].Normal.Normalize(); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); } 阅读教程6-1中的“归一化法线”理解为何需要最后的for循环。 注意:因为XNA没有提供一个包含3D位置、颜色、法线的顶点结构,这个教程使用一个蓝色的纹理让每个像素的颜色都是一样的。通过这种方式,你看到的颜色中的所有变化都会被光照影响。 然后就可以使用逐像素光照绘制三角形了: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = blueTexture; basicEffect.TextureEnabled = true; basicEffect.LightingEnabled = true; Vector3 lightDirection = new Vector3(3, -10, 0); lightDirection.Normalize(); basicEffect.DirectionalLight0.Direction = lightDirection; basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; basicEffect.PreferPerPixelLighting = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 6); pass.End(); } basicEffect.End();

在场景中添加光线——在顶点间共享法线

clock 二月 14, 2011 14:27 by author alex
问题 在前面的教程中,你学习了如何根据法线数据使三角形获取正确的光照。 但是,盲目地将这个方法施加到所有三角形中往往得不到最好的效果。 如果三角形的每个顶点具有相同的法线方向,那么光照是一样的,所有像素会获得同样的光照。如果两个相邻三角形(不在同一平面)也是如此施加光照,那么一个三角形的所有像素获得同样的光照,另一个三角形的所有像素获得另一个光照。这会导致很容易地可见看见两者的边界,因为两个三角形有不同的颜色。 如果三角形中的颜色是平滑过渡的,你想获取一个更好的效果。要做到这点,从一个三角形到另一个三角形的明暗应该平滑过渡。 解决方案 显卡是根据三角形的三个顶点计算明暗的。三角形中的所有像素的明暗会进行插值。如果三个顶点的法线是相同的,所有像素会获得相同的明暗。如果不同,像素的明暗在角中会平滑过渡。 看一下两个三角形,共享一条边,由六个顶点组成。这种情况如图6-3的左图所示。要保证从一个三角形到另一个三角形的颜色能够平滑过渡, 你需要确保两个边界的颜色是一样的。这可以通过让共享的顶点具有相同的法线实现。在图6-3的左图,顶点1和4,顶点2和3有相同的法线。 图6-3 两个共享一条边的三角形 工作原理 本教程中,你将使用两个方法定义两个三角形。首先,定义两个所有顶点都有相同法线的三角形,这会导致三角形中的所有像素都有相同的光照。然后,你要确保共享顶点中的法线是相同的,这会在三角形边界上获得光滑的明暗效果。 每个三角形拥有各自的法线 这个方法获取垂直于三角形的方向并将这个方向存储在顶点中。 下面的代码定义了如图6-3左图中所示的六个顶点。每个三角形的三个顶点具有相同的法线方向,垂直于三角形。左边的三角形垂直放置,所以它的法线向左。第二个三角形水平放置,法线向上。 private void InitVertices() { vertices = new VertexPositionNormalTexture[6]; vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), new Vector3(-1, 0, 0), new Vector2(0,1)); vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 1), new Vector3(-1, 0, 0), new Vector2(0.5f, 0)); vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(-1, 0, 0), new Vector2(0.5f, 1)); vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0), new Vector3(0, 1, 0), new Vector2(0.5f,1)); vertices[4] = new VertexPositionNormalTexture(new Vector3(0,- 1), new Vector3(0, 1, 0), new Vector2(0.5f,1)); vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0), new Vector3(0, 1, 0), new Vector2(1,1)); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); } 然后定义一个稍微偏右下方向的光线。确保归一化这个光线的方向: Vector3 lightDirection = new Vector3(10, -2, 0); lightDirection.Normalize(); basicEffect.DirectionalLight0.Direction = lightDirection; 然后绘制两个三角形: basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } basicEffect.End(); 可参见教程6-1中的“归一化法线”一节理解为何需要归一化光线方向。 这时应该看到两个三角形,都有一个不透明颜色,如图6-4左图所示。你可以轻易地看到两者间的边界,在绘制大物体时这并不是你想要的结果。 图6-4 三角形着色(左图),共享的顶点着色(右图) 共享法线 这次,你将在顶点1和4、顶点2和3上施加同样的法线方向。带来的一个问题是你应该选择哪个方向。 要获取最光滑的效果,你只需简单地求法线的平均值,如下面的代码所示: private void InitVertices() vertices = new VertexPositionNormalTexture[6]; Vector3 normal1 = new Vector3(-1, 0, 0); Vector3 normal2 = new Vector3(0, 1, 0); Vector3 sharedNormal = normal1 + normal2; sharedNormal.Normalize(); vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), normal1, new Vector2(0,1)); vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 1), sharedNormal, new Vector2(0.5f, 0)); vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), sharedNormal, new Vector2(0.5f, 1)); vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0), sharedNormal, new Vector2(0.5f,1)); vertices[4] = new VertexPositionNormalTexture(new Vector3(0,- 1), sharedNormal, new Vector2(0.5f,1)); vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0), normal2, new Vector2(1,1)); myVertexDeclaration=new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); 首先求出两个三角形的法线之和,然后归一化结果(见教程6-1中的“归一化法线”一节)。计算的结果会指向左和上之间。 然后定义六个顶点。两侧的两个顶点不共享法线,所以仍保持原来的法线值。其他四个共享法线的顶点使用相同的法线。 现在,当绘制两个三角形时,明暗会光滑地从一个两侧的顶点过渡到共享的边,如图6-4右图所示。让你很难看到两个三角形的共同边,这样用户不会发现物体是由多个三角形组成的。 技巧:教程5-7介绍了如何给一个大物体自动计算共享法线。 当使用索引时也可以使用这个方法(参见教程5-3)。 代码 在教程的前面可以找到定义三角形的代码,下面的代码用来绘制三角形: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = blueTexture; basicEffect.TextureEnabled = true; basicEffect.LightingEnabled = true; Vector3 lightDirection = new Vector3(10, -2, 0); lightDirection.Normalize(); basicEffect.DirectionalLight0.Direction = lightDirection; basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } basicEffect.End(); 注意:这个教程使用的纹理只有一个不透明的颜色(蓝色), 你可以确认最后的颜色渐变效果。

在场景中添加光线——定义法线和使用BasicEffect

clock 二月 14, 2011 14:24 by author alex
问题 没有正确地光照,场景会缺乏真实感。在某些情况中,如果光照不正确3D效果会完全消失。 例如,一个有着不透明颜色的球,如果没有光照,球的所有像素将会是相同的颜色,在屏幕上看起来像一个平面的盘子。当光照正确时,球上面对光照的部分比其他部分的颜色更亮,使球看起来是一个真实的3D对象。 解决方案 在计算机图形学中,所有的3D对象都是由三角形组成的。你想使三角形对应入射光可以正确地被照亮。图6-1显示了一条从左向右的单向光,它影响到一个矩形的六个不同位置,每个矩形都是由两个三角形组成的。 图6-1 根据入射光的三角形光照情况 简单地定义光源的位置和对象的位置不足以让显卡在对象上添加正确地光照。对3D对象的每个三角形来说,你需要添加一些信息,是显卡可以计算照射到表面的光照强度。 这可以通过指定每个顶点的法线向量做到,法线在图6-1中用三角形顶点上的线段表示。一旦为每个顶点定义了正确的法线,BasicEffect就能使用正确的光照绘制对象。 工作原理 在图6-1中,绘制了一些矩形(每个矩形都是由两个三角形组成的),它们的颜色表示入射光强度。矩形越垂直于入射光方向,接受到的光越多。最后一个矩形垂直于光线方向被完全照亮。而第一个矩形平行于光线,所以不接受光照。 定义法线 那么显卡是如何知道相关信息的呢?在三角形的每个顶点上,你将定义垂直于三角形的方向。这个方向叫做法线。法线方向在图6-1中表示为伸出三角形顶点的线段。因为垂直于平面的方向是唯一的,所以矩形上所有顶点具有相同的法线方向。 法线方向让显卡可以计算三角形上获得的光线。如教程6-5所述,你可以将法线投影到光线方向上,如图6-2所示。光线方向用图片底部的长箭头表示,从左指向右。 图6-2中的旋转的黑色条代表图6-1中的矩形。法线在光线方向上的投影用光线箭头上的粗黑色块表示,黑色块越大,三角形获得的光越多。 图6-2 将法线投影到光线方向上 图中左边的三角形的法线垂直于光线方向,所以投影为0,三角形不受光照。右边的三角形的法线平行于光线方向,投影最大,被完全照亮。 给定光线和法线,显卡可以很容易地计算出投影长度。这就是显卡正确计算光照的方法。 将光照施加到场景中 显卡会计算每个顶点上的光强,然后用这个值乘以像素的初始颜色。 在顶点中添加法线数据 前面的段落中解释了除了包含3D位置和颜色,每个顶点中还需存储法线方向。 XNA拥有一个预定义的顶点格式可以在每个顶点中保存法线: VertexPositionNormalTexture结构。这个格式让你可以为每个顶点保存3D位置,法线方向和纹理坐标。可参加教程5-2学习带纹理的三角形,参加教程5-12学习如何自定义顶点格式。 下面的方法创建了一个拥有6个顶点的数组,定义了两个三角形形成一个矩形。这个矩形平躺在地面上,所以所有Y坐标都是0。因此,法线的方向为(0,1,0) Up方向,因为这就是垂直于矩形的方向。顶点格式还要有纹理坐标,这样显卡才知道如何从图像采样颜色(见教程5-2)。 private void InitVertices() { vertices = new VertexPositionNormalTexture[6]; int i = 0; vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,1)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1)); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); } 最后一行代码确保VertexDeclaration (见教程5-1)只被创建一次,因为它不需要改变。 技巧:如果三角形是平铺在地面上的,很容易计算法线。可参加教程5-7学习如何为一个复杂的对象自动计算法线。 正法线和负法线 一个三角形其实有两个垂直方向。如果如前面的代码三角形是在地面上的,你可以定义一个指向上方或下方的法线。那么应该使用哪一个?这很重要,因为选择了错误的方向会导致错误的光照,通常会不受光照。 原则是,你需要选择指向对象外部的那个法线。 通常,三角形的一个面可以看做一个3D物体的“里面”,而另一个面就是“外面”。在这种情况下,你想选择指向物体外部的法线。 设置BasicEffect参数 定义好顶点后就可以绘制三角形了。本章的第二部分解释了如何编写HLSL effects,但第一个教程只使用BasicEffect。BasicEffect是一个预定义的effect,可以用来绘制使用基本光照效果的物体。确保有一个BasicEffect变量,因为每帧都创建一个新BasicEffect对象太耗费资源。在类顶部添加这个变量: BasicEffect BasicEffect basicEffect; 在LoadContent方法中实例化basicEffect : basicEffect = new BasicEffect(device, null); 下面的设置将从一个单向光绘制3D场景的光照,例如像太阳: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; basicEffect.LightingEnabled = true; basicEffect.AmbientLightColor = new Vector3(0.1f, 0.1f, 0.1f); basicEffect.PreferPerPixelLighting = true; basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 0); basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; basicEffect.DirectionalLight1.Enabled = false; basicEffect.DirectionalLight2.Enabled = false; 代码的第一部分设置了World,View和Projection矩阵,用来将3D场景转换到2D屏幕,具体知识可参见教程2-1和4-2。因为在每个顶点中存储了纹理坐标,你还要将一张纹理传递到显卡并开启它,纹理的知识详见教程5-2。 接着设置光照。首先开启光照并定义一个环境光颜色,这个颜色是对象一直都受到的,无论它相对于光线的朝向如何。这里你定义了暗灰色,这样即使没有光线,物体仍会隐约可见。 如果显卡可以处理逐像素光照,你可以开启它,可参加教程6-3。 最后定义光源。使用BasicEffect时,可以一次使用三个光源。你需要设置光源方向和颜色,并开启光源。本例中只使用一个光源。 设置了BasicEffect后,就可以使用BasicEffect绘制三角形了: basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } basicEffect.End(); 注意:如果你通过将basicEffect. LightingEnabled设置为false关闭了灯光,会以全部光强绘制场景。如前面“将光照施加到场景中”一段解释的那样,显卡会在每个像素的初始颜色上乘以光照强度。如果关闭灯光,显卡会简单地绘制每个像素的初始颜色。实际上对应lighting factor为1的情况,意思是全部光强。 使用世界矩阵 在绘制三角形前,你可以设置effect的世界矩阵。这样你可以将三角形移动到另一个位置或缩放旋转它们(见教程4-2)。构建良好的effects (例如BasicEffect)将这个变换施加到顶点中的法线数据上。例如,前面的代码中的矩形若发生旋转,法线也会跟着一起旋转。 下面的代码显示了这样一个例子。 归一化法线 注意:在阅读此节时,你需要知道normal (法线)和normalize(归一化)的区别。法线是垂直于三角形的方向。归一化表示使一个向量的长度为单位长度,当归一化一个向量时,你将它的长度减少(或增加)到1。 光照强度只应该由光线和法线间的夹角决定。但是,显卡的计算结果还取决于两者的长度。所以,你必须确保法线方向和光线方向的长度都为1,这可以通过归一化实现。 当两者都是单位长度时,光照强度只取决于两者间的夹角,这正是你想要的结果。 代码 首先要提供每个顶点的法线数据: private void InitVertices() { vertices = new VertexPositionNormalTexture[6]; int i = 0; vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,1)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,0)); vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1)); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); } 在使用BasicEffect绘制三角形前,你需要设定它的参数定义光照环境: basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; basicEffect.LightingEnabled = true; basicEffect.DirectionalLight0.Direction = new Vector3(1, 0, 0); basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3(); basicEffect.DirectionalLight0.Enabled = true; 最后绘制三角形。下面的代码绘制了两个相同的三角形九次,每次都使用一个不同的世界矩阵。世界矩阵首先选择三角形然后将它沿x轴移动四个单位(参见教程5-2学习矩阵乘法的顺序)。 for (int i = 0; i < 9; i++) { basicEffect.World = Matrix.CreateTranslation(4, 0, 0) * Matrix.CreateRotationZ((float)i * MathHelper.PiOver2 / 8.0f); basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } basicEffect.End(); } 可参见教程5-1学习如何绘制三角形。

在场景中添加光线——概述

clock 二月 14, 2011 14:21 by author alex
给场景添加光照听起来很简单:设置3D世界中物体的位置,定义光源的位置就可以了。但是,虽然看起来让显卡实现光照很简单,其实不是。 对物体的每个表面,显卡需要计算表面接受光照的数量,这个光照数量是基于光线方向和表面法线方向的夹角的。幸运的是,XNA框架拥有BasicEffect,它可以为你进行所有的计算。本章第一部分解释如何使用BasicEffect给场景添加光照。 但是,如它的名称所示,BasicEffect只能用来计算基本的光照。如果你想在场景中添加点光源该怎么办呢,例如一个蜡烛?或许你还想在场景中添加多个光源,给物体添加阴影。 要解决这些问题,你需要在HLSL中编写自己的effect。本章的第二部分首先介绍创建与BasicEffect相同功能的HLSL effects。 然后你要学习如何实现额外的功能,诸如支持点光源和聚光灯。最后一个教程介绍延迟绘制(deferred rendering),让你可以同时使用大量的光源照亮场景,你还将学习如何使用延迟绘制组合阴影映射技术让物体产生阴影。 本章的教程包含以下内容: 学习XNA 3.0中光照的基本知识,包括正确的光照需要什么以及为什么需要(教程6-1和6-2). 使用BasicEffect计算屏幕上所有像素的光照,在反光表面添加镜面高光(教程6-3和6-4) 当BasicEffect功能不足时编写自己的HLSL effect。首先实现BasicEffect的基本功能(教程6-5, 6-7和6-9) 实现额外的功能,诸如支持点光源和聚光灯(教程6-6和6-8) 使用多个光源让场景更生动。因为基本方法不够用,你需要使用延迟绘制在屏幕中添加光照(教程6-10) 使用阴影映射技术添加阴影。你使用延迟绘制方法实现这个效果,让你可以实现多个光源的阴影(教程6-11)

处理顶点——为赛道创建顶点

clock 二月 14, 2011 14:16 by author alex
问题 给定三维空间中的一个基点集合,你想创建一条通过所有点的赛道。你想创建顶点,计算法线,在赛道上贴上纹理。 解决方案 你可以经过几个步骤从3D点的集合创建一个赛道。首先使用教程5-16中讨论的3维Catmull-Rom插值生成位于你预定义的点之间的样条上的额外点。第一步可用图5-33中的从“a”指向“b的箭头表示”。 基于这些点无法定义三角形。对样条上的每个点,你要计算垂直于样条的方向,在样条两侧各添加一个点,如图5-33中的“b”所示。 图5-33 为赛道生成顶点 最后,通过将这些新计算的点转换为顶点创建一个TriangleList,如图5-36的“d”所示。 工作原理 首先需要定义一些3D点。例如下面的集合定义了赛道类似于8的形状: List<Vector3> trackPoints = new List<Vector3>(); trackPoints.Add(new Vector3(2, 0, 4)); trackPoints.Add(new Vector3(0, 0, 0)); trackPoints.Add(new Vector3(-2, 0, -4)); trackPoints.Add(new Vector3(2, 0, -4)); trackPoints.Add(new Vector3(0, 1, 0)); trackPoints.Add(new Vector3(-2, 0, 4)); 注意:最后一个点比其他点高一个单位,所以你没必要将红绿灯放置在赛道上。 计算基点之间的额外点 首先使用Catmull-Rom插值计算基点间的许多额外点,如图5-34的”b”所示。在教程5-16中创建的InterpolateCR方法很有用,因为它可以计算任意两个基点间的额外点。 但是,要创建两个基点间的额外点你还需要提供两个邻近的基点。例如,如果你想计算图5-34中点1和点2间的额外点,需要将点0, 1, 2,3传递到InterpolateCR方法中。 图5-34 使用Catmull-Rom的头尾连接 通常来说,如果你想计算基点i和i+1之间的额外点,你需要提供点i-1, i,i+1和i+2。这在赛道末端会导致一个问题,如图5-34所示。这个赛道由八个点组成,你可以毫无问题地构建位于[1,2], [2,3], [3,4], [4,5]和[5,6]之间的部分。但是,当计算[6,7]间的额外点时,你需要传递点5, 6, 7和8。但因为集合中只包含基点0到7,这里就会出现问题。更坏的是,要计算最后的7到0,0到1两段时,你还需要基点9和10,它们也不存在。 幸运的是,你知道赛道的最后一点连接到第一点,所以你知道基点8就是基点0。同样的道理,基点9就是基点1,基点10就是基点2。这意味着你可以将基点0, 1和2添加到集合尾部来解决这个问题。这一步需要在GenerateTrackPoints方法的一开始进行,这个方法会创建整个赛道的所有额外点的集合: private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints) { basePoints.Add(basePoints[0]); basePoints.Add(basePoints[1]); basePoints.Add(basePoints[2]); List<Vector3> allPoints = new List<Vector3>(); for (int i = 1; i < basePoints.Count-2; i++) { List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]); allPoints.AddRange(part); } allPoints.Add(allPoints[0]); return allPoints; } 将前三个基点复制到点集合的尾部后,你创建了一个新的空集合,这个集合包含了赛道的所有中心点,对应图5-33中的“b”。 在for循环中会从一段跳到下一段,使用InterpolateCR方法创建一段的额外点,并将所有点都添加到allPoints集合中。 对每一段,for循环会调用InterpolateCR方法,传递基点i-1,i,i+1和i+2,i从1开始。这意味着第一段从[ 1,2]开始,如图5-34左图所示。InterpolateCR方法会返回基点1和2之间的基点1和19个额外点。这20个点会被添加到allPoints集合中。 最后一段会被添加到[8,9]之间,与[0,1]相同。 注意:你可以通过InterpolateCR 方法中的detail 变量调整额外点的数量。 for循环会继续直到所有部分的所有额外点都被添加到allPoints集合中。 现在你知道了赛道的所有中心点,如图5-33的“b”所示。 计算外部的side点 对每个点你想在赛道的两边各定义一个新的点,如图5-33中“c”,这样你可以将它们连接在一起定义三角形。 要做到这一点,首先需要计算每个点的side方向。Side方向垂直于汽车行驶的方向和垂直于赛道的法线方向。这里(0,1,0) Up向量作为赛道上每个点的法线方向。 如果知道两个方向,你可以叉乘这两个方向获取垂直于这两个方向的方向。本例中,这两个方向是(0,1,0) Up方向和汽车的行驶方向,行驶方向是从当前点指向下一点的方向,你可以通过将当前点减去下一个点获取这个方向: Vector3 carDir = basePoints[i + 1] - basePoints[i]; Vector3 sideDir =Vector3.Cross(new Vector3(0, 1, 0), carDir); sideDir.Normalize(); Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth; 获取了side方向,你将它乘以赛道宽度并加/减当前点。这时,你就计算了图5-33 中“c”的side点。 创建顶点 在为这些点创建顶点前,你需要提供它们的法线和纹理坐标。这里你将(0,1,0) Up向量作为法线方向。 纹理坐标有点难。如果你在每个顶点的Y坐标上添加了一个常量,那么在基点位置互相接近的位置的纹理会缩在一起。这是因为InterpolateCR方法会在任何情况下都要添加20个额外点,无论是两个基点非常靠近还是远离。 要解决这个问题,需要保存将两点间的距离保存在一个distance变量中,比较长的部分要比比较短的部分增加更多的Y坐标: VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength)); verticesList.Add(vertex); distance += carDir.Length(); 注意:d值在处理好赛道上每个点之后才增加。纹理的Length变量让你可以缩放赛道纹理。你需要对赛道的每个中心点都进行这样的操作: private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3> basePoints) { float halfTrackWidth = 0.2f; float textureLength = 0.5f; float distance = 0; List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>(); for (int i = 1; i < basePoints.Count-1; i++) { Vector3 carDir = basePoints[i + 1] - basePoints[i]; Vector3 sideDir = Vector3.Cross(new Vector3(0, 1, 0), carDir); sideDir.Normalize(); Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth; VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength)); verticesList.Add(vertex); istance += carDir.Length(); } VertexPositionNormalTexture extraVert = verticesList[0]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); extraVert = verticesList[1]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); return verticesList.ToArray(); } for循环之后,verticesList包含了赛道每个中心点的两个顶点。但是,当你从顶点集合绘制三角形时,仍会在最后一个中心点和第一个中心点之间出现缝隙。要连接这个缝隙,你需要将前两个中心点的side点复制到集合中。但是,因为前两个顶点的Y坐标为0,你需要将它们调整到当前的纹理坐标值。否则,最后两个三角形将会将它们的Y坐标回到0,导致大量的纹理堆砌在两个小三角形上。 最后,将集合转换为数组并返回调用代码。 绘制赛道 有了基点和定义了方法后,添加以下代码将基点转换为一个顶点数组: List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints); trackVertices = GenerateTrackVertices(extendedTrackPoints); 定义了顶点后,就可以在屏幕上绘制一些三角形了: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = road; basicEffect.TextureEnabled = true; basicEffect.VertexColorEnabled = false; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip,trackVertices, 0, trackVertices.Length - 2); pass.End(); } basicEffect.End(); 可见教程6-1学习如何绘制一个TriangleList和教程6-2学习如何绘制一个带纹理的三角形。 围栏 前面的代码生成了一个完全平坦的赛道。这是因为赛道的每个点的法线都是(0,1,0) Up方向。虽然这样做已经可以产生一个可用的赛道,但你还想让汽车保持在赛道上而不致冲出赛道。 要让赛道看起来更加真实,你需要在赛道上加上护栏防止车飞出赛道。 前面的代码使用(0,1,0) Up向量作为法线计算side向量。这次,你使用一个更加合适的法线向量。基于护栏计算法线几乎是不可能的。解决这个问题的方法是记住最后一个法线向量并对赛道上的每个点调整这个法线。 使用这个方法,你需要给法线指定一个初始值。如果你确定赛道的起点是平的,可以简单地使用(0,1,0) Up向量作为初始法线,所以在for循环中定义以下代码: Vector3 currentNormal = Vector3.Up; 对赛道上的每个点,你需要考虑赛道的弯曲程度调整这个向量。 这个弯曲程度可以用离心方向表示:这个方向就是你将车拉回的方向,使车不至于飞出赛道外,如图5-35所示。 图5-35 找到离心(centrifugal)方向 你可以通过两个叉乘获取这个方向。首先,需要叉乘指向当前点的汽车行驶的方向(lastCarDir)和指向下一个点的汽车应该行驶的方向(carDir)。这两个方向如图5-35的左图所示。计算的结果向量垂直于这两个方向,指向纸外,所以很难在纸上表示出来。然后,叉乘这个结果向量和carDir,获取centriDir向量,这个向量总是指向弯曲赛道的内侧。 图5-35的右图显示了一个复杂的3D情况。 下面是代码: Vector3 carDir = trackPoints[i + 1] - trackPoints[i]; carDir.Normalize(); Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1]; lastCarDir.Normalize(); Vector3 perpDir = Vector3.Cross(carDir, lastCarDir); Vector3 centriDir = Vector3.Cross(carDir, perpDir); 因为centriDir方向指向曲线的内侧,所以赛道围栏必须垂直于这个向量。 基于以上理由,需要在赛道的每个顶点的法线向量中添加这个向量。这会让side向量慢慢地垂直于centriDir向量。 但是在一个很长的过程中,这会变得太突出,所以你需要添加某个复位因子。对这个复位因子使用Up向量可以让法线在过程结束时恢复到Up向量: currentNormal = currentNormal + centriDir * banking + Vector3.Up/banking; currentNormal.Normalize(); banking变量的值越大, 赛道添加的围栏越多。这里起你可以使用前面的代码,但别忘了用currentNormal代替(0,1,0) Up向量: Vector3 sideDir = Vector3.Cross(currentNormal, carDir); sideDir.Normalize(); currentNormal = Vector3.Cross(carDir, sideDir); 当使用这个代码时,你的赛道就拥有了围栏。而且,这个代码还可以让赛道循环! 代码 GenerateTrackPoints方法接受基点的数组并进行了扩展,让这个方法可以添加赛道的细节: private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints) { basePoints.Add(basePoints[0]); basePoints.Add(basePoints[1]); basePoints.Add(basePoints[2]); List<Vector3> allPoints = new List<Vector3>(); for (int i = 1; i < basePoints.Count-2; i++) { List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]); allPoints.AddRange(part); } return allPoints; } 基于这个扩展过的集合,GenerateTrackVertices方法创建了一个可以绘制带有围栏的赛道顶点数组: private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3>trackPoints) { float halfTrackWidth = 0.2f; float textureLength = 0.5f; float banking = 2.0f; float distance = 0; List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>(); Vector3 currentNormal = Vector3.Up; for (int i = 1; i < trackPoints.Count-1; i++) { Vector3 carDir = trackPoints[i + 1] - trackPoints[i]; carDir.Normalize(); Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1]; lastCarDir.Normalize(); Vector3 perpDir =Vector3.Cross(carDir, lastCarDir); Vector3 centriDir = Vector3.Cross(carDir,perpDir); currentNormal = currentNormal + Vector3.Up/banking + centriDir * banking; currentNormal.Normalize(); Vector3 sideDir = Vector3.Cross(currentNormal, carDir); sideDir.Normalize(); currentNormal = Vector3.Cross(carDir, sideDir); Vector3 outerPoint = trackPoints[i] + sideDir * halfTrackWidth; Vector3 innerPoint = trackPoints[i] - sideDir * halfTrackWidth; distance += carDir.Length(); VertexPositionNormalTexture vertex; vertex = new VertexPositionNormalTexture(innerPoint, currentNormal, new Vector2(0, distance / textureLength)); verticesList.Add(vertex); vertex = new VertexPositionNormalTexture(outerPoint, currentNormal, new Vector2(1, distance / textureLength)); verticesList.Add(vertex); } VertexPositionNormalTexture extraVert = verticesList[0]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); extraVert = verticesList[1]; extraVert.TextureCoordinate.Y = distance / textureLength; verticesList.Add(extraVert); return verticesList.ToArray(); } 有了这两个方法,可以很容易地从一个3D点的集合绘制一条赛道: List<Vector3> basePoints = new List<Vector3>(); basePoints.Add(new Vector3(2, 0, 4)); basePoints.Add(new Vector3(0, 0, 0)); basePoints.Add(new Vector3(-2, 0, -4)); basePoints.Add(new Vector3(2, 0, -4)); basePoints.Add(new Vector3(0, 1, 0)); basePoints.Add(new Vector3(-2, 0, 4)); List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints); trackVertices = GenerateTrackVertices(extendedTrackPoints);

处理顶点——在3D空间中使用Catmull-Rom插值生成额外的顶点

clock 二月 14, 2011 14:14 by author alex
问题 给定一个3D空间中的点序列,你想构建一个漂亮的,光滑曲线可以通过所有这些点。图5-32中的黑色曲线显示了这样条曲线,灰色线段表示使用简单的线性插值的情况,可参见教程5-9。 图5-32 通过5点的Catmul-Rom样条(spline) 这在许多情况中是很用的。例如,你可以用它产生一个赛道,可参见教程5-17。当相机非常靠近模型或地形时,你可以使用Catmull-Rom插值产生额外的顶点,使模型或地形看起来更加光滑。 解决方案 如果你想在两个基点之间生成Catmull-Rom样条,你还需要知道两个邻近基点。在图5-32中,当你想在点1和点2之间生成曲线时,你需要知道基点0, 1, 2和3的坐标。 XNA已经带有一维的Catmull-Rom功能。你可以将任意四个基点传入MathHelper. CatmullRom方法,这个方法可以为你计算第二和第三个点之间的曲线。你还需要传递在0和1范围内的第五个参数,表示你想要第二和第三个点之间的哪个点。 在本教程中,你将这个功能扩展到3维并创建一个方法用来生成多个基点之间的一根样条。 工作原理 XNA提供了一维的单个值的Catmul-Rom插值算法。因为Vector3只是三个单个值的组合,所以你可以通过在Vector3的X, Y和Z分量上调用一维XNA方法实现Catmull-Rom插值 。下面的方法是XNA默认方法的3D扩展,它接受四个Vector3而不是四个单个值。 private Vector3 CR3D(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float amount) { Vector3 result = new Vector3(); result.X= MathHelper.CatmullRom(v1.X, v2.X, v3.X, v4.X, amount); result.Y = MathHelper.CatmullRom(v1.Y, v2.Y, v3.Y, v4.Y, amount); result.Z = MathHelper.CatmullRom(v1.Z, v2.Z, v3.Z, v4.Z, amount); return result; } 这个方法会返回一个叫做result的Vector3,位于v2和v3之间的样条上。变量amount让你可以指定v2,result和v3之间的距离,当amount为0时返回v2,1返回v3。 使用CR3D方法计算样条 有了计算3维Catmull-Rom插值的方法,就可以生成样条的多个点了。给定3维空间中的四个基点v1,v2,v3和v4,下面的方法返回v2和v3之间的20个点的集合,第一个点位于v2。 private List<Vector3> InterpolateCR(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) { List<Vector3> list = new List<Vector3>(); int detail = 20; for (int i = 0; i < detail; i++) { Vector3 newPoint = CR3D(v1, v2, v3, v4, (float)i / (float)detail); list.Add(newPoint); } return list; } 如果你想添加/移除v2和v3之间的点,可以增加/减少detail值(或更好的做法,将detail值作为这个方法的参数)。 注意:点集合中的最后一个点不是v3。这是因为最后一个点调用CR3D方法时,传递的参数是19/20而不是1。 你需要手动添加v3,例如,添加这行代码:list.Add(v3);。 将样条的多个部分连接在一起 前面的代码生成了位于四个点中间两点之间的样条,下面的代码表示如何扩展样条: points.Add(new Vector3(0,0,0)); points.Add(new Vector3(2,2,0)); points.Add(new Vector3(4,0,0)); points.Add(new Vector3(6,6,0)); points.Add(new Vector3(8,2,0)); points.Add(new Vector3(10, 0, 0)); List<Vector3> crList1 = InterpolateCR(points[0], points[1], points[2], points[3]); List<Vector3> crList2 = InterpolateCR(points[1], points[2], points[3], points[4]); List<Vector3> crList3 = InterpolateCR(points[2], points[3], points[4], points[5]); straightVertices = XNAUtils.LinesFromVector3List(points, Color.Red); crVertices1 = XNAUtils.LinesFromVector3List(crList1, Color.Green); crVertices2 = XNAUtils.LinesFromVector3List(crList2, Color.Blue); crVertices3 = XNAUtils.LinesFromVector3List(crList3, Color.Yellow); 首先在3D空间定义七个(译者注:原文如此,不过好像是六个)点的集合。然后,使用前四个点生成points [ 1]和points [ 2]之间的额外点。然后切换到下一个点获取points [2]和points [ 3]之间的额外点。最后获取了points[3]和points[4]之间的额外点。 这意味着你最终获得了许多额外点,所有这些点都在从points[1]出发,经过points[2]和point[3],终止于points[4]的样条上(看一下图5-32加深理解)。 因为你想绘制这些点,所以要从样条的这三个部分生成顶点。首先要将七个基点转换为顶点,这样就可以绘制线段了。使用下面的方法进行此步: public static VertexPositionColor[] LinesFromVector3List(List<Vector3> pointList, Color color) { myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements); VertexPositionColor[] vertices = new VertexPositionColor[pointList.Count]; int i = 0; foreach (Vector3 p in pointList) vertices[i++] = new VertexPositionColor(p, color); return vertices; } 上述方法只是简单地将集合中的每个Vector3转换为一个顶点,并将它和选择的颜色存储在一个数组中。 你可以绘制位于这些顶点间的线段,可参加教程6-1: basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.VertexColorEnabled = true; basicEffect.TextureEnabled = false; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, straightVertices, 0, straightVertices.Length - 1); device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices1, 0, crVertices1.Length - 1); device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices2, 0, crVertices2.Length- 1); device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices3, 0, crVertices3.Length - 1); pass.End(); } basicEffect.End(); 代码 这个教程介绍了两个方法。第一个是XNA自带的Catmull-Rom 插值算法在3维空间中的扩展: private Vector3 CR3D(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float amount) { Vector3 result = new Vector3(); result.X = MathHelper.CatmullRom(v1.X,v2.X, v3.X, v4.X, amount); result.Y = MathHelper.CatmullRom(v1.Y, v2.Y, v3.Y, v4.Y, amount); result.Z = MathHelper.CatmullRom(v1.Z, v2.Z, v3.Z, v4.Z, amount); return result; } 第二个方法可以计算通过四个基点的样条上额外点: private List<Vector3> InterpolateCR(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) { List<Vector3> list = new List<Vector3>(); int detail = 20; for (int i = 0; i < detail; i++) { Vector3 newPoint = CR3D(v1, v2, v3, v4, (float)i / (float)detail); list.Add(newPoint); } list.Add(v3); return list; }

处理顶点——在3D世界添加水面

clock 二月 14, 2011 14:12 by author alex
问题 你可以在CPU上计算所有波的3D位置,但这样做会消耗大量的资源,每帧向显卡发送大量的数据是不可接受的。 解决方案 在XNA程序中,你只需创建一个三角形组成的平面网格。这和创建地形类似,在教程5-8中已经解释过了,只不过这次网格是平的,你将需一次性地把这些数据传递到显卡。当绘制网格时,vertex shader会在网格上添加水波,pixel shader添加反射,这一切都在GPU中完成,CPU只处理Draw指令,让CPU可以处理更重要的工作。 vertex shader接受网格的顶点数据并改变它们的高度,这样平的网格会形成一个波涛起伏的海面。你可以使用一个正弦波产生这个高度。只有一个正弦波会导致海面看起来太理想化,这是因为每个波都是一样的。 幸运的是,GPU上的所以操作在一次并行处理中能执行四次,所以计算一个正弦波和计算四个正弦波所用的时间是相同的。vertex shader会计算四个正弦波并将它们求和,如图5-32所示。当你对每个波设置不同的波长、波速和振幅,最终会形成比较真实的海面。 图5-32 四个正弦波的叠加 只有海面反射周围环境时它才会看起来足够真实。在pixel shader中,你将从天空盒(见教程2-8)采样反射颜色。但是,这些反射太完美的话会形成像玻璃一样的水洋面,反而不真实。所以在每个像素中,你还要在水面上添加凹凸映射(见教程5-16),在大的海浪上再增加一些小的逐像素的漪涟。 最后使用菲涅耳项调整最后结果,这样pixel shader会根据视角在深蓝色和反射颜色之间进行插值。 工作原理 在XNA项目中,你需要导入并绘制一个天空盒(见教程2-8).接着,你需要为平面网格生成顶点和索引,这个只是教程5-8中地形生成的简化版本。使用如下代码生成顶点: private VertexPositionTexture[] CreateWaterVertices() { VertexPositionTexture[] waterVertices = new VertexPositionTexture[waterWidth * waterHeight]; int i = 0; for (int z = 0;z < waterHeight; z++) { for (int x = 0; x < waterWidth; x++) { Vector3 position = new Vector3(x, 0, -z); Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f); waterVertices[i++] = new VertexPositionTexture(position, texCoord); } } return waterVertices; } 这个网格是平的,因为所有Y值都是0。在顶点中只存储了3D位置和纹理坐标,生成索引的方法已在教程5-8中介绍过了。定义好顶点和索引后,你就做好了编写HLSL effect文件的准备了。 XNA-to-HLSL变量 你想让海面的生成尽可能的灵活,所以期望可以在XNA项目中设置很多变量。因为你处理的3D位置要被转换到2D屏幕坐标,所以需要World,View和Projection矩阵。接着你想完全控制四个正弦波,每个波都有四个可控变量:振幅、波长、波速和方向。 因为你想让vertex shader在每帧都更新波,所以需要知道当前时间;要添加水面反射,需要知道相机的3D位置。最后,要在水面通过凹凸贴图添加漪涟,你还要一个凹凸贴图,还要两个变量设置凹凸贴图的强度和漪涟的大小: float4x4 xWorld; float4x4 xView; float4x4 xProjection; float4 xWaveSpeeds; float4 xWaveHeights; float4 xWaveLengths; float2 xWaveDir0; float2 xWaveDir1; float2 xWaveDir2; float2 xWaveDir3; float3 xCameraPos; float xBumpStrength; float xTexStretch; float xTime; 你需要一个天空盒采样反射的颜色,还有一个凹凸贴图: Texture xCubeMap; samplerCUBE CubeMapSampler =sampler_state { texture = <xCubeMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU =mirror; AddressV = mirror; }; Texture xBumpMap; sampler BumpMapSampler =sampler_state { texture = <xBumpMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; Output结构 要查询反射的颜色,pixel shader需要知道相机位置。需要根据pixel shader 中对凹凸贴图的处理构建出Tangent-to-World矩阵。 pixel shader只需计算每个像素的颜色: struct OWVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3 Pos3D: TEXCOORD1; float3x3 TTW: TEXCOORD2; }; struct OWPixelToFrame { float4 Color : COLOR0; }; Vertex Shader:正弦波 vertex shader中最重要的任务是调整每个顶点的高度。前面已经解释了,你将四个正弦函数进行叠加生成水波。正弦函数需要一个参数,当这个参数增加时,会形成在–1到1之间的波形。你使用顶点位置作为正弦函数的一个参数,这个参数与波的传播方向相关。你可以将顶点的X,Z位置与传播方向点乘,对于垂直于波的传播方向上一直线上的所有顶点来说,这个点乘值是相同的。如果参数相同,那么正弦函数也相同,所以它们的高度也是相同的。 这让波在正确地方向上波形良好。 OWVertexToPixel OWVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { OWVertexToPixel Output = (OWVertexToPixel)0; float4 dotProducts; dotProducts.x = dot(xWaveDir0, inPos.xz); dotProducts.y = dot(xWaveDir1, inPos.xz); dotProducts.z = dot(xWaveDir2, inPos.xz); dotProducts.w = dot(xWaveDir3, inPos.xz); } 因为shader允许你将四个正弦函数求和,你需要对顶点的XZ坐标和波的方向进行四次点乘。在使用这些点乘作为正弦函数的参数之前,需要将它们除以xWaveLengths变量,这样可以调整波长。 最后要使水波移动,所以需要在参数中添加当前时间。将xWaveSpeeds 变量乘以当前时间,让你可以定义每列波的波速,让某些波可以比其他波运动得更快或是更慢。下面是代码: float4 arguments = dotProducts/xWaveLengths+xTime*xWaveSpeeds; float4 heights = xWaveHeights*sin(arguments); 正弦函数的结果乘以xWaveHeights变量,这样你可以独立地缩放四个波形。记住这个代码是并行地执行了四次,让你可以快速地获取当前时间当前顶点的正弦波的高度。 计算完正弦函数后,将它们相加作为顶点的Y坐标: float4 final3DPos = inPos; final3DPos.y += heights.x; final3DPos.y += heights.y; final3DPos.y += heights.z; final3DPos.y += heights.w; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(final3DPos, preWorldViewProjection); float4 final3DPosW = mul(final3DPos, xWorld); Output.Pos3D = final3DPosW; 这可以有效地将顶点高度提升。一旦你知道了顶点的最终位置,你就可以将它们转换为2D屏幕位置了。 pixel shader也需要知道3D位置,所以你要将3D位置信息传递到pixel shader (因为Output.Position不是在pixel shader中被处理的)。 Pixel Shader:最简单的形式 在vertex shader转换了顶点的3D位置后,这个简单的pixel shader会给每个像素施加同样的颜色并将它们绘制到屏幕: OWPixelToFrame OWPixelShader(OWVertexToPixel PSIn) : COLOR0 { OWPixelToFrame Output = (OWPixelToFrame)0; float4 waterColor = float4(0,0.1,0.3,1); Output.Color = waterColor; return Output; } 只能看到一片蓝色混在一起上下起伏 定义Technique 添加technique定义使effect可用。如果你每帧更新xTime变量,那么波也会在vertex shader中更新: technique OceanWater { pass Pass0 { VertexShader = compile vs_2_0 OWVertexShader(); PixelShader = compile ps_2_0 OWPixelShader(); } } Vertex Shader:法线计算 所有光线计算都要用到顶点的法线,在你的XNA项目中你无需添加顶点的法线信息。法线向量必须垂直于表面,因为vertex shader在不停地改变表面,所以在vertex shader中还要计算法线。 对于一个平面,法线的方向是(0,1,0)向上。当水波通过平面时,你需要知道(0,1,0)向量会偏离多少。你可以通过求导算出一个函数(例如正弦函数)上任意点的偏移量。听起来很难,但幸运的是正弦函数的导数是余弦函数。如果太抽象,可以看一下图5-33。实线表示正弦函数,你可以看到虚线非常好(事实上是非常完美)地显示了波形上任意点的法线的偏移程度。 图5-33 正弦函数(实线)和余弦函数(虚线) 例如,在波峰和波谷(这里水面是平的),余弦值为0,这表示(0,1,0)向量无需改变,而在波的平衡位置余弦函数最大。 这意味着计算完正弦函数后,还要对高度函数求导。因为高度函数要乘以xWaveHeights,所以求导函数也要乘以xWaveHeights。正弦函数的导数是余弦函数,在将dotProducts (图5-33中的水平轴)除以xWaveLengths时也要将它除以xWaveLengths。 float4 derivatives = xWaveHeights*cos(arguments)/xWaveLengths; 上面公式的意义是:波越高,则波形越陡,法线的偏移量就越大,而波长越长,法线偏离(0,1,0) 方向就越少。 注意:正弦和余弦都乘以xWaveHeights这意味这如果你将高度设置为0,那么顶点就没有起伏或法线就没有偏离。 现在你已经计算出了导数,将它们相加获得当前顶点的法线偏移量。图5-33中的波是2D的,所以余弦值表示法线向前或向后的偏移量。而你的波是在3D空间中的,所以前面的话中你应该用“波的传播方向”代替“向前”: float2 deviations = 0; deviations += derivatives.x*xWaveDir0; deviations += derivatives.y*xWaveDir1; deviations += derivatives.z*xWaveDir2; deviations += derivatives.w*xWaveDir3; float3 Normal = float3(-deviations.x, 1, -deviations.y); 顶点法线为(0,1,0)表示几乎不偏离,对应为波峰波谷的顶点,如果波长很短那么法线几乎都是指向波的传播方向的。 Vertex Shader: 创建Tangent-to-World矩阵 当你使用前一节中生成的法线给水面添加光照时,明暗效果已经很好了,但是你还要在水面上添加一些小起伏。要添加这个细节,你需要使用凹凸映射。在pixel shader中进行凹凸映射前,你需要生成正确的Tangent-to-World矩阵。 如教程5-16中解释的那样,这个矩阵中的行对应法线、副法线和切线向量。现在你已经有了法线向量,现在继续定义切线和副法线向量: float3 Binormal = float3(1, deviations.x, 0); float3 Tangent = float3(0, deviations.y, 1); 这三个向量必须彼此垂直。因为你要让法线偏离(0,1,0)方向,所以你需要将副法线偏离(1,0,0)方向,切线偏离(0,0,1)方向。 知道了切线空间的三个向量后,就很容易定义Tangent-to-World矩阵,如教程5-16中解释的那样: float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(Tangent); tangentToObject[2] = normalize(Normal); float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld; Output.TexCoord = inTexCoord+xTime/50.0f*float2(-1,0); return Output; 你将Tangent-to-World 矩阵和用来从凹凸映射中采样的纹理坐标传递到pixel shader。 Pixel Shader:凹凸映射 在每个像素中,你将改变法线方向用来添加水面的小起伏。如果使用凹凸贴图,你可以很容易地在最后结果中看到凹凸贴图上的图案。对每个像素,你都要采样凹凸贴图的三个颜色通道并对结果求平均。记住颜色总是定义在[0,1]区间,所以你将颜色的每个分量减去0.5使它们在[–0.5,0.5]区间。然后在下一步把它们映射到[–1,1]区间(法线的XYZ坐标区间)。 float3 bumpColor1 = tex2D(BumpMapSampler, xTexStretch*PSIn.TexCoord)-0.5f; float3 bumpColor2 = tex2D(BumpMapSampler, xTexStretch*1.8*PSIn.TexCoord.yx)-0.5f; float3 bumpColor3 = tex2D(BumpMapSampler, xTexStretch*3.1*PSIn.TexCoord)-0.5f; float3 normalT = bumpColor1 + bumpColor2 + bumpColor3; 这三个纹理坐标是不同的,因为它们乘以不同的因子。第二个纹理的XY坐标已经被改变,最后,将三个偏移量相加。 这个方向必须要归一化,但在这之前,你还可以缩放凹凸贴图。在凹凸贴图中,蓝色向量对应默认法线向量,红色和绿色对应副法线和切线的偏移量,所以如果你增加/减少红色或绿色,就可以增加/减少偏移量,并由此改变了凹凸映射! normalT.rg *= xBumpStrength; normalT = normalize(normalT); float3 normalW = mul(normalT, PSIn.TTW); 上面代码中获取的方向需要归一化并转换到世界空间。你最终得到世界空间中的法线,这样可以和其他世界空间中的向量进行计算。 Pixel Shader:反射 有了定义在世界空间中的法线向量,现在可以计算水面的光照了。但是为了使效果更真实,你需要首先添加水面的反射。因为反射颜色是从天空盒采样的,所以需要从天空盒采样的方向(见教程2-8)。你可以将eye向量取关于法线的镜像获取这个反射方向,如图5-34所示。 图5-34 将eye向量关于法线取镜像获得reflection向量 你可以将目标点减去初始点获取eye向量: float3 eyeVector = normalize(PSIn.Pos3D - xCameraPos); 因为eye向量盒法线向量都是在定义在间中,你可以对他们进行操作。HLSL提供了reflect 方法可以计算如图5-34的反射向量: float3 reflection = reflect(eyeVector, normalW); 如果你从这个方向采样天空盒纹理,就可以获得当前像素的反射颜色(可见教程2-8获取更多关于texCUBE的知识): float4 reflectiveColor = texCUBE(CubeMapSampler, reflection); Pixel Shader:菲涅耳项 如果你只是简单地施加反射颜色,那么水面任何地方反射程度都一样,这会导致水面像一面起伏的镜子。要解决这个问题,你需要根据观察角度调整反射率。如果你平行于水面观察,反射率会很高,如果垂直观察,反射率低,水面会呈现深蓝色。 译者注:在《新概念物理-光学教程》第276页,引用了M.C.Escher的名画《三界(Three Worlds)》形象地表明了:同是在水与空气的界面上的光,来自于入射角大的远处,以反射为主,只能看到树的倒影;来自于入射角小的近处,以透射为主,即反射率很小,可以清楚地看到水下的鱼。 这可以使用eye向量盒法线向量的点乘表示。如果你垂直观察水面,那么这两个向量夹角为0,点乘积最大(如果两个向量都是单位向量则为1)。如果你平行于水面观察,这两个向量的夹角为90度,点乘积为0。Eye向量盒法线向量的点乘叫做菲涅耳项(Fresnel term)。 菲涅耳项大(接近1)表示最小的反射,而菲涅耳项小的像素表示它的行为像一面镜子。水面看起来永远不会像一面镜子,所以你要把菲涅耳项从[0,1]区间缩放到[0.5,1]区间来降低反射率: float fresnelTerm = dot(-eyeVector, normalW); fresnelTerm = fresnelTerm/2.0f+0.5f; 注意:代码第一行中的负号是必须的,这是因为这两个向量方向相反,法线指向上方而eye向量指向下方,如果没有负号会导致菲涅耳项为负数。 效果已经不错了,但还没加上对太阳的反射效果 Pixel Shader:镜面反射 现在你已经知道了反射的颜色,反射与深蓝色混合的程度,下面你可以通过添加一些镜面反射来改进最后的效果。 如教程6-4中解释的那样,镜面反射通常取决于入射光的方向。在本例中,通过找到反射立方贴图中光源的水面上的点的方式获取更好的效果,它们通常对应于天空盒的亮白色部分。 要找到明亮的反射位置,要将反射颜色的三个颜色通道相加求和,明亮的部位和会接近于3,将这个值除以3使之处于[0,1]区间。 float sunlight = reflectiveColor.r; sunlight += reflectiveColor.g; sunlight += reflectiveColor.b; sunlight /= 3.0f; float specular = pow(sunlight,30); 将这个值进行30的幂计算,这样可以只剩下最亮的颜色,只有大于0.975的sunlight值才会得到超过0.5的specular值! Pixel Shader:整合在一起 最后,你将所有要素组合在一起获得最终的颜色,下面是代码: float4 waterColor = float4(0,0.2,0.4,1); Output.Color = waterColor*fresnelTerm + reflectiveColor*(1-fresnelTerm) + specular; 深蓝色的水面颜色和反射颜色的联系是由菲涅耳项决定的。对几乎所有像素,这这种颜色的组合构成了最终的颜色。对于一个有很亮反光颜色的像素,specular值会大于1,这会让最终颜色亮得多,有着非常亮的反射的水面位置对应的是天空盒中太阳的位置。 最终效果 代码 XNA部分 在XNA项目中,你可以完全控制波。你可以独立地设置四个波的波速,振幅和波长。如果你想移除一个波,只需将它的振幅设置为0即可。下面的代码设置effect的所有参数并绘制海面的三角形: Vector4 waveSpeeds = new Vector4(1, 2, 0.5f, 1.5f); Vector4 waveHeights = new Vector4(0.3f, 0.4f, 0.2f, 0.3f); Vector4 waveLengths = new Vector4(10, 5, 15, 7); Vector2[] waveDirs = new Vector2[4]; waveDirs[0] = new Vector2(-1, 0); waveDirs[1] = new Vector2(-1, 0.5f); waveDirs[2] = new Vector2(-1, 0.7f); waveDirs[3] = new Vector2(-1, -0.5f); for (int i = 0; i < 4; i++) waveDirs[i].Normalize(); effect.CurrentTechnique = effect.Techniques["OceanWater"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xBumpMap"].SetValue(waterBumps); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xBumpStrength"].SetValue(0.5f); effect.Parameters["xCubeMap"].SetValue(skyboxTexture); effect.Parameters["xTexStretch"].SetValue(4.0f); effect.Parameters["xCameraPos"].SetValue(fpsCam.Position); effect.Parameters["xTime"].SetValue(time); effect.Parameters["xWaveSpeeds"].SetValue(waveFreqs); effect.Parameters["xWaveHeights"].SetValue(waveHeights); effect.Parameters["xWaveLengths"].SetValue(waveLengths); effect.Parameters["xWaveDir0"].SetValue(waveDirs[0]); effect.Parameters["xWaveDir1"].SetValue(waveDirs[1]); effect.Parameters["xWaveDir2"].SetValue(waveDirs[2]); effect.Parameters["xWaveDir3"].SetValue(waveDirs[3]); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(waterVertexBuffer, 0, VertexPositionTexture.SizeInBytes); device.Indices = waterIndexBuffer; device.VertexDeclaration = myVertexDeclaration; device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, waterWidth *waterHeight, 0, waterWidth * 2 * (waterHeight - 1) - 2); pass.End(); } effect.End(); 注意:波的参数只有在波变化时才需要被设置,而其他参数,如time,view matrix和camera position需要每帧都更新或当相机位置变动时更新。 HLSL部分 你可以在本教程开始部分找到XNA-to-HLSL变量,纹理和输出结构。 下面是vertex shader的代码,vertex shader中不停地改变每个顶点的高并计算Tangent-to-World矩阵: OWVertexToPixel OWVertexShader(float4 inPos: POSITION0, float2 inTexCoord:TEXCOORD0) { OWVertexToPixel Output = (OWVertexToPixel)0; float4 dotProducts; dotProducts.x = dot(xWaveDir0, inPos.xz); dotProducts.y = dot(xWaveDir1, inPos.xz); dotProducts.z = dot(xWaveDir2, inPos.xz); dotProducts.w = dot(xWaveDir3, inPos.xz); float4 arguments = dotProducts/xWaveLengths+xTime*xWaveSpeeds; float4 heights = xWaveHeights*sin(arguments); float4 final3DPos = inPos; final3DPos.y += heights.x; final3DPos.y += heights.y; final3DPos.y += heights.z; final3DPos.y += heights.w; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(final3DPos, preWorldViewProjection); float4 final3DPosW = mul(final3DPos, xWorld); Output.Pos3D = final3DPosW; float4 derivatives = xWaveHeights*cos(arguments)/xWaveLengths; float2 deviations = 0; deviations += derivatives.x*xWaveDir0; deviations += derivatives.y*xWaveDir1; deviations += derivatives.z*xWaveDir2; deviations += derivatives.w*xWaveDir3; float3 Normal = float3(-deviations.x, 1, -deviations.y); float3 Binormal = float3(1, deviations.x, 0); float3 Tangent = float3(0, deviations.y, 1); float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(Tangent); tangentToObject[2] = normalize(Normal); float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld; Output.TexCoord = inTexCoord+xTime/50.0f*float2(-1,0); return Output; } pixel shader将深蓝色的水面颜色与反射颜色混合,混合程度取决于视角,由菲涅耳项表示。镜面反光项会在对应环境中光源的水面上的位置添加高光。 OWPixelToFrame OWPixelShader(OWVertexToPixel PSIn) : COLOR0 { OWPixelToFrame Output = (OWPixelToFrame)0; float3 bumpColor1 = tex2D(BumpMapSampler, xTexStretch*PSIn.TexCoord)-0.5f; float3 bumpColor2 = tex2D(BumpMapSampler, xTexStretch*1.8*PSIn.TexCoord.yx)-0.5f; float3 bumpColor3 = tex2D(BumpMapSampler, xTexStretch*3.1*PSIn.TexCoord)-0.5f; float3 normalT = bumpColor1 + bumpColor2 + bumpColor3; normalT.rg *= xBumpStrength; normalT = normalize(normalT); float3 normalW = mul(normalT, PSIn.TTW); float3 eyeVector = normalize(PSIn.Pos3D - xCameraPos); float3 reflection = reflect(eyeVector, normalW); float4 reflectiveColor = texCUBE(CubeMapSampler, reflection); float fresnelTerm = dot(-eyeVector, normalW); fresnelTerm = fresnelTerm/2.0f+0.5f; float sunlight = reflectiveColor.r; sunlight += reflectiveColor.g; sunlight += reflectiveColor.b; sunlight /= 3.0f; float specular = pow(sunlight,30); float4 waterColor = float4(0,0.2,0.4,1); Output.Color = waterColor*fresnelTerm + reflectiveColor*(1-fresnelTerm) + specular; return Output; }

处理顶点——通过切线空间的凹凸映射添加逐像素细节

clock 二月 14, 2011 14:05 by author alex
问题 虽然前一个教程中具有不变法线的平面物体工作良好,但如果对一个曲面或有转角的表面进行凹凸映射仍会遇到麻烦。 主要问题是包含在凹凸映射中的偏离法线是在切线空间中的,这意味着它与默认法线有联系。 为了形象化的说明这个问题,设想绘制一个圆柱体,如图5-30所示。左图表示圆柱体顶点的默认法线。 图5-30 圆柱体的默认法线和凹凸映射法线 想象一下你想对这个圆柱体使用凹凸映射。例如,你想对圆柱体的所有像素使用包含 (–1,0,1)方向法线的凹凸映射。这个法线相对于默认法线向左偏转45度。 获取这个法线正确的方式是位于默认法线的起点沿着这个默认法线向左旋转45度。看一下顶点法线0,4和6,对于圆柱体的每个像素这个方向是不同的,因为这个方向取决于像素的默认法线!如果对每个法线都进行这样的处理,你最终会获得如右图所示的结果。这些法线都相对于原始法线向左偏离45度。 但是,如果你想使用这个法线计算光照,需要找到法线(–1,0,1)对应于世界空间的方向。这是必须的,因为你需要使用两个向量进行光照计算,而这两个向量需定义在同一个空间中。你想在XNA程序中指定光线在世界空间中的方向。 解决方案 法线映射中的三个颜色通道包含了切线空间中的法线坐标,即相对于默认法线而言,这个坐标表示默认法线的偏离量。每个像素的局部坐标系是不同的,因为这取决于默认法线,而在一个曲面上默认法线的方向是不同的。这个局部坐标系叫做切线空间。 技巧:要理解切线空间,可以想象你站在如图5-30所示的圆柱体像素上,沿着法线方向。现在将你的上方(沿着默认法线方向)作为Z轴,你的右边就是X轴,前方就是Y轴。 这个切线坐标系统如图5-31中“a”图的三个灰色箭头表示。注意像素的默认法线在切线空间中是Z轴(见前面的技巧)。切线空间的x和y轴要与z轴互相垂直,z垂直与物体(因为这是默认法线方向),x轴和y轴与物体相切,分别对应切线和副法线。 从凹凸贴图采样的三个颜色通道包含定义在切线空间的法线数据。由图中的黑色箭头表示,对应偏移量为X=0.3,Y=0和Z=0.8的法线。在“a”中,你看到了一个偏向x轴的新法线。 最后要计算像素的发光,这需要获取定义在切线空间中的法线和定义在世界空间中的光线方向的点乘。而要计算两个向量的点乘,这两个向量必须在同一个空间中。所以你要么将光线方向从世界空间转换到切线空间,要么将法线从切线空间转换到世界空间。在本教程中,我们先使用第二种方法。教程的最后一段会使用第一种方法。 图5-31 在切线空间(a),模型空间(b)和世界空间(c)中的偏离法线 工作原理 对每个像素,你都要采样凹凸贴图获取相对于切线空间的偏离法线。最后要知道这个法线在世界空间中的坐标,然后才能与光线方法进行点乘。 切线空间到世界空间的转换 从凹凸贴图采样的法线是定义在切线空间中的,例如一个圆塔是圆柱形的,如图5-31所示。每个像素的偏离法线需要首先转换到模型空间,即塔的坐标空间。这会得到模型空间中的默认法线。 技巧:要理解塔的坐标空间,想象一下你站在塔的初始位置,例如塔内部的中心位置。现在,你的上方是y轴,右方是x轴,后方是z轴。你想得到的偏离法线是由塔的坐标系统指定的。 这个变换由图5-31中的从“a”指向“b”的箭头表示。在“a”中,切线空间中的法线坐标是(0.3,0,0.8),而在“b”中模型空间中的法线坐标是(0.2,0,0.85)(由两个系统的坐标决定)。 你可以使用一个包含旋转的世界矩阵绘制一个塔(例如,绘制一个比萨斜塔)。在本例中,你获取的法线需要移除这个旋转才能获得法线的绝对(世界)方向。这个变换由图5-31中的从“b”指向“c”的箭头表示(注意这个图中的模型坐标实际上是世界坐标的旋转)。最后,你获得了世界坐标中的法线。 定义自定义顶点格式 要将法线从切线空间转换到模型空间,你需要乘以正确的变换矩阵。你需要对每个像素都做这样的操作。要创建这个矩阵,首先要知道每个顶点在切线空间的x,y和z轴(法线,切线和副法线) 。 因为默认法线和z轴是不同的,而且x轴和y轴必须垂直于z轴,所以通常每个顶点这三个轴是不同的。因为三个轴正交,所以你只需知道任意两个,就可以通过叉乘它们获取第三个轴。一个轴(z)是默认法线,可以使用教程5-7中的代码获取,切线向量由模型提供,或者在简单的情况中(例如一个圆柱体)由自己定义。 注意:本例中,vertex shader会通过叉乘法线和切线向量计算出副法线向量。这样的话,顶点只需存储法线和切线向量。你也可以编写一个自定义模型处理器计算每个顶点的副法线并将它存储在顶点中,只需使用MeshHelper. CalculateTangentFrames帮你完成这个繁重的工作。这样做的好处是显卡无需在每帧计算副法线向量了。 首先你需要定义一个自定义顶点格式存储每个顶点的3D位置,纹理坐标,法线和切线。可见教程5-14获取自定义顶点格式的细节。 public struct VertPosTexNormTan { public Vector3 Position; public Vector2 TexCoords; public Vector3 Normal; public Vector3 Tangent; public VertPosTexNormTan(Vector3 Position, Vector2 TexCoords, Vector3 Normal, Vector3 Tangent) { this.Position = Position; this.TexCoords = TexCoords; this.Normal = Normal; this.Tangent = Tangent; } public static readonly VertexElement[] VertexElements = { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, sizeof(float)*3, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, sizeof(float)*(3+2), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Normal, 0), new VertexElement(0, sizeof(float)*(3+2+3), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Tangent, 0), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 3 + 3); } 每个顶点需要存储一个Vector3表示位置,一个Vector2表示纹理坐标,两个Vector3表示法线和切线,一共11个浮点数,而副法线在vertex shader中计算。 定义每个顶点的法线和切线 本例中,你定义了一些三角形创建一座塔(一个圆柱形的墙),使用教程5-7的代码生成法线数据。 如前所述,切线方向垂直于法线,与塔表明相切。本例中你定义一个垂直的塔,所以你知道向上方向不与塔相交,并垂直于塔的所有法线,这就是切线方向。 下面的代码定义一个圆柱形。对于每个生成的顶点计算出3D位置,并将(0,1,0)向上方向做为切线方向。 private void InitVertices() { List<VertPosTexNormTan> verticesList = new List<VertPosTexNormTan>(); int detail = 20; float radius = 2; float height = 8; for (int i = 0; i < detail + 1; i++) { float angle = MathHelper.Pi * 2.0f / (float)detail * (float)i; Vector3 baseVector = Vector3.Transform(Vector3.Forward, Matrix.CreateRotationY(angle)); Vector3 posLow = baseVector * radius; posLow.Y = -height / 2.0f; Vector3 posHigh = posLow; posHigh.Y += height; Vector2 texCoordLow = new Vector2(angle / (MathHelper.Pi * 2.0f), 1); Vector2 texCoordHigh = new Vector2(angle / (MathHelper.Pi * 2.0f), 0); verticesList.Add(new VertPosTexNormTan(posLow, texCoordLow, Vector3.Zero, new Vector3(0, 1, 0))); verticesList.Add(new VertPosTexNormTan(posHigh, texCoordHigh, Vector3.Zero, new Vector3(0, 1, 0))); } vertices = verticesList.ToArray(); } 接下来,基于顶点生成索引,所有的代码已在教程5-7中解释了: vertices = InitVertices(); indices = InitIndices(vertices); vertices = GenerateNormalsForTriangleList(vertices, indices); 设置了顶点和索引,下面可以处理. fx文件了。 XNA-to-HLSL变量 所有的3Dhaders都需要传递World,View和Projection矩阵。因为没有光线凹凸映射不起作用,所以还要设置光线方向。最后,xTexStretch 变量让你可以定义砖块纹理的平铺次数。 你需要一张颜色纹理采样颜色,还需要包含定义在切线空间中的法线信息的凹凸贴图。 vertex shader总是要把每个顶点的3D位置转换到2D屏幕坐标,还要传递纹理坐标。要让pixel shader将法线从切线空间转换到世界空间,vertex shader还要计算Tangent-to-World矩阵传递到pixel shader。 float4x4 xWorld; float4x4 xView; float4x4 xProjection; float3 xLightDirection; float xTexStretch; Texture xTexture; sampler TextureSampler = sampler_state { texture = <xTexture> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; Texture xBumpMap; sampler BumpMapSampler = sampler_state { texture = <xBumpMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; struct BMVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3x3 TTW : TEXCOORD1; }; struct BMPixelToFrame { float4 Color : COLOR0; } 注意:这个代码使用了语义TEXCOORD1传递了一个3 × 3矩阵。这会被编译,但背景使用了TEXCOOR2和TEXCOORD3,所以你不能再使用它们了。 Vertex Shader vertex shader将3D位置转换到2D屏幕坐标并将纹理坐标传递到pixel shader: BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0) { BMVertexToPixel Output = (BMVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.TexCoord = inTexCoord; return Output; } 接下来你要在vertex shader中添加一些代码计算Tangent-to-World矩阵。如前所述,这个转换包含两个过程。首先将法线的坐标从切线空间转换到模型空间,然后转换到世界空间。 首先定义一个tangentToObject矩阵,要定义这个矩阵,你需要知道坐标轴的基本向量——切线空间的法线,切线和副法线。 vertex shader接受每个顶点的法线和切线。如前所述,你可以通过叉乘两者计算出副法线,因为副法线向量是与法线和切线正交的。 float3 Binormal = normalize(cross(inTangent,inNormal)); 计算了副法线后就可以构建Tangent-to-Object矩阵了,因为这个矩阵的三行分别代表这三个向量,这些向量都需要被归一化: float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(inTangent); tangentToObject[2] = normalize(inNormal); 技巧:归一化矩阵的每行后,因为这三个向量是正交的,所以这个矩阵的逆矩阵等于它的转置矩阵,你可以通过将矩阵中的元素关于左上角至右下角的元素做镜像获得它的转置矩阵,这样可以更容易地计算逆矩阵。 你要做的就是通过将Tangent-to-Object矩阵和Object-to-World矩阵相乘把两者组合起来。Object-to-World矩阵通常就是世界矩阵(例如包含塔的旋转),所以最后的代码是: float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld; 将这个矩阵传递到pixel shader。现在pixel shader可以很容易地通过把向量乘以这个矩阵把向量从切线空间转换到世界矩阵! Pixel Shader 在vertex shader做完了全部准备工作,pixel shader看起来就很简单了: BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0 { BMPixelToFrame Output = (BMPixelToFrame)0; float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float3 normalW = mul(normalT, PSIn.TTW); float lightFactor = dot(-normalize(normalW),normalize(xLightDirection)); float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch); Output.Color = lightFactor*texColor; return Output; } 首先采样凹凸映射,这个颜色中包含三个颜色通道对应在切线空间中的偏离法线的坐标。 在前一个教程中的“Pixel Shader”一节中提到,颜色通道的区间是0至1,而法线坐标的区间是–1到1。所以首先将这个值减0.5映射到[-0.5,0.5]区间再乘以2映射到[–1,1]区间。计算结果存储在normalT变量中。 normalT变量包含定义在切线空间中的偏离法线的数据。 normalT值为(0,0,1)表示偏离法线等于默认法线(即切线空间中的z轴方向)。normalT值为(0,0.7,0.7)表示法线在默认法线和切线(切线空间的y轴方向)间的45度角方向。 将normalT 乘以Tangent-to-World 矩阵就可以完成从切线空间到世界空间的转换,获取normalW的值,这个值就是世界空间中的法线。 注意:你可以立即将法线从切线空间转换到世界空间,因为 Tangent-to-World矩阵是Tangent-to-Object矩阵和Object-to-World矩阵的组合。 最后,当你知道了世界空间中的法线后,就可以将这个向量乘以光线向量了,如教程6-1中解释的,这个点乘给出了光线强度,把这个强度乘以颜色,因为每个像素的法线不同,所以每个像素的反光程度也不同。 代码 HLSL部分 shader要计算Tangent-to-World矩阵传递给pixel shader使用: BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0) { BMVertexToPixel Output = (BMVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.TexCoord = inTexCoord; float3 Binormal = cross(inTangent,inNormal); float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal); tangentToObject[1] = normalize(inTangent); tangentToObject[2] = normalize(inNormal); float3x3 tangentToWorld = mul(tangentToObject, xWorld); Output.TTW = tangentToWorld; return Output; } pixel shader采样凹凸贴图并将颜色映射到[–1,1]区间获取切线空间的法线,再乘以Tangent-to-World矩阵获取世界空间中的法线,然后点乘光线方向: BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0 { BMPixelToFrame Output = (BMPixelToFrame)0; float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float3 normalW = mul(normalT, PSIn.TTW); float lightFactor = dot(-normalize(normalW),normalize(xLightDirection)); float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch); Output.Color = lightFactor*texColor; return Output; } XNA部分 XNA项目要提供每个顶点的切线,切线向量垂直于法线,生成这些顶点的方法前面已经写过了。 最后需要设置effect参数并绘制三角形,代码如下: effect.CurrentTechnique = effect.Techniques["BumpMapping"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xTexture"].SetValue(myTexture); effect.Parameters["xBumpMap"].SetValue(myBumpMap); effect.Parameters["xLightDirection"].SetValue(lightDirection); effect.Parameters["xTexStretch"].SetValue(4.0f); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserIndexedPrimitives<VertPosTexNormTan>(PrimitiveType.TriangleList, vertices, 0, vertices.Length, indices, 0, indices.Length/3); pass.End(); } effect.End(); 相反的方法 前面的代码显示了如何构建tangentToWorld矩阵,这样每个像素都可以将它的法线从切线空间转换到世界空间。这一步需要在与光线方向点乘前完成,因为光线方向是在世界空间中的。 你也可以换种方法:你可以将光线方向从世界空间转换到切线空间并点乘切线空间中的法线。这很有趣,因为每个像素的光线方向是一样的,所以可以在vertex shader中转换光线方向并将它传递给pixel shader。通过这种方法,pixel shader可以立即进行点乘计算而无需计算任何转换! 注意:使用这个方法,每个三角形只需要进行三次光线向量转换,而不是对三角形的每个像素的法线进行转换。 你将转换到切线空间中的的光线向量传递到pixel shader,而不是传递tangentToWorld矩阵: struct BMVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3 LightDirT: TEXCOORD1; }; 在vertex shader中,你已经计算了tangentToWorld矩阵。这次,你想将光线向量从世界空间转换到切线空间,所以需要求tangentToWorld矩阵的逆矩阵。 计算逆矩阵是一个复杂的运算。幸运的是,tangentToWorld矩阵是由三个正交、归一化(副法线,切线和法线)的向量构成的。通常,你在乘法操作中以一个向量作为第一个参数,一个矩阵作为第二个矩阵: float3 vectorW = mul(vectorT, tangentToWorld); 在三个正交归一化向量的特殊情况下,你可以通过改变乘法的顺序获取逆矩阵转换一个向量: float3 vectorT = mul(tangentToWorld, vectorW); 注意:这是因为由三个正交并归一化的向量构成的矩阵的逆矩阵等于它的转置矩阵。 这正是你想获得的结果:将光线向量从世界空间转换到切线空间: Output.LightDirT = mul(tangentToWorld, xLightDirection); 计算好切线空间的光线方向后,把把它传递到pixel shader。因为从映射中采样的法线和光线方向都是在切线空间中,你可以直接点乘两者: float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch); float3 normalT = (bumpColor - 0.5f)*2.0f; float lightFactor = dot(-normalize(normalT), normalize(PSIn.LightDirT));

处理顶点——凹凸映射:固定法线

clock 二月 14, 2011 13:59 by author alex
问题 三角形的最主要问题是它是平的。如果你使用两个三角形绘制一堵巨大的墙并在墙上附上一个漂亮的纹理,结果是令人失望的平的。 你可以将三角形分割成更小的三角形以添加细节,这需要定义每个顶点的3D位置,但这样做会消耗太多的资源。 解决方案 你可以使用凹凸映射代替上述这个丑陋的方法。凹凸映射通过改变三角形每个像素的颜色给观察者留下三角形表面高低起伏的印象。 如果你看一张平整的红色的塑料板的图片,会发现所有像素几乎是一个颜色的。但是如果是一个粗糙表面的图片,比方说一块红砖,像素会有不同的红色,让观察者知道这块砖的表面是粗糙的。 这就是你想模拟的效果。在一个粗糙表面上,像素反光情况不同导致颜色是不同的。这是因为你可以把一块砖分成上千个小的表面,每个面都有不同的方向或者说不同的法线,如图5-29左边所示。对应每个不同的法线,你可以获取不同的光照情况 (见第6章)。 图5-29 一个有着上千个平面的砖块,每个面都有不同的法线 你可以不使用如图5-29左边所示的上百个三角形绘制砖块,而是如右图所示只用两个三角形绘制。在这两个三角形的每个像素上,你将稍微改变一下默认的法线,这会让所有像素有不同的光照并导致产生不同的颜色。通过这种方式,如果砖块位置或光线方向改变时,凹凸映射可以添加很多细节。 要获得最好的效果,你不能随机改变法线的方向,而是应该以正确的方式进行,如右图所示,注意右图的法线与左图是一样的。通常每个法线的X,Y和Z 坐标都存储在一张图像的R,G和B通道中,这叫做纹理的凹凸映射(或法线映射)。 所以,不要将砖块分成上千个三角形,而是使用两张纹理绘制有两个三角形的矩形:一张纹理包含砖块的颜色,另一张包含法线偏离量的信息。你需要使用pixel shader查询每个像素的颜色和法线偏移量,并使用这个法线计算光照进一步调整颜色的亮度。 本教程的凹凸映射是一个平面,平面是个简化的例子,因为它的法线向量是不变的。 工作原理 首先你需要定义一个新的三角形表示平面。下面的代码定义两个带纹理的三角形,这足以绘制一个大矩形了: private VertexPositionTexture[] InitVertices() { VertexPositionTexture[] vertices = new VertexPositionTexture[6]; vertices[0] = new VertexPositionTexture(new Vector3(-5, 0, 10), new Vector2(0, 2)); vertices[1] = new VertexPositionTexture(new Vector3(-5, 0, -10), new Vector2(0, 0)); vertices[2] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2)); vertices[3] = new VertexPositionTexture(new Vector3(-5, 0, -10),new Vector2(0, 0)); vertices[4] = new VertexPositionTexture(new Vector3(5, 0, -10), new Vector2(1, 0)); vertices[5] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2)); return vertices; } 因为你要从纹理进行采样,所以还要指定纹理坐标(见教程5-2)。因为Y坐标都是0,所以你定义的平面在XZ平面上,所以你知道默认法线向量是朝上的。 注意:这个假设只有在这种情况下才是对的,下一个教程会介绍一个更通用的方法。 定义好顶点后,就可以开始编写fx代码了。 XNA-to-HLSL变量 所有的3D effects都需要传递世界矩阵、观察矩阵和投影矩阵,这样vertex shader才能将顶点的3D位置转换到2D屏幕位置。要显示凹凸映射的效果,你还应该可以改变光线方向。 如前所述,你还要传入两张纹理:一张用来采样颜色,一张用来查询每个像素的法线。 float4x4 xWorld; float4x4 xView; float4x4 xProjection; float3 xLightDirection; Texture xTexture; sampler TextureSampler = sampler_state { texture = <xTexture> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; Texture xBumpMap; sampler BumpMapSampler = sampler_state { texture = <xBumpMap> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = wrap; AddressV = wrap; }; struct SBMVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct SBMPixelToFrame { float4 Color : COLOR0; }; vertex shader会输出每个顶点的2D屏幕位置和纹理坐标,pixel shader计算最终颜色。 Vertex Shader vertex shader非常简单,因为它只需将3D坐标转换到2D屏幕空间并将纹理坐标传递给pixel shader: SBMVertexToPixel SBMVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { SBMVertexToPixel Output = (SBMVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.TexCoord = inTexCoord; return Output; } 3D位置通过世界矩阵、视矩阵、投影矩阵的组合转换到2D屏幕空间,纹理坐标只是直接传递到pixel shader。 Pixel Shader 对于三角形的每个像素,pixel shader都会从颜色纹理中查询颜色值。但这次pixel shader 还要采样凹凸贴图,查询指定像素偏离默认法线的方向。 法线向量是一个3D向量,由三个坐标轴定义。有关法线更多的信息可见教程5-7,要知道法线如何影响光线,可见教程6-1。 对凹凸映射的每个像素,法线坐标是保存在三个颜色通道中的。但是,这三个坐标变化范围应该在–1和1 (也就是说可以是±x,±y和±z方向)之间,但颜色的范围只是在[0,1]范围中。所以,你需要将[0,1]范围中的值映射到[–1,1]之间,这可以先将这个值减去0.5(映射到 [–0.5, 0.5]范围内)在乘以2 (映射到[–1,1]范围内)。这一步是在pixel shader中的第三行代码中进行的: SBMPixelToFrame SBMPixelShader(SBMVertexToPixel PSIn) : COLOR0 { SBMPixelToFrame Output = (SBMPixelToFrame)0; float3 bumpMapColor = tex2D(BumpMapSampler, PSIn.TexCoord).rbg; float3 normalFromBumpMap = (bumpMapColor - 0.5f)*2.0f; float lightFactor = dot(-normalize(normalFromBumpMap), normalize(xLightDirection)); float4 texColor = tex2D(TextureSampler, PSIn.TexCoord); Output.Color = lightFactor*texColor; return Output; } 在凹凸映射中,三个颜色通道表示默认法线的偏移量。当默认法线不偏离时,对应的像素颜色是浅蓝(R=0.5, G=0.5, B=1,译者注:见上面的算法,以rbg顺序读取颜色,然后使用公式乘2减1,即R=0.5, G=0.5, B=1结果是(0,1,0))。在这个例子中的两个三角型是在XZ平面中,所以法线朝上,即(0,1,0)的方向。 凹凸映射中像素的颜色不是(R=0.5, G=0.5, B=1)表示法线需要偏离(0,1,0)方向。本例中,你想让法线朝X或Z方向偏离。可见下一个教程学习一个通用的方法。 这也是为什么这个例子中使用蓝色通道对应法线的Y坐标,红色和绿色通道对应 X和Z 坐标(这也是使用RBG swizzle的理由,译者注:swizzle是一种指定输入哪个通道的方法,例如float4 b(1, 2, 3, 4);则float4 a = b.xxxx(); // a is now 1, 1, 1, 1;a = b.wwwz(); // a is now 4, 4, 4, 3;a = b.yzzw(); // a is now 2, 3, 3, 4;a = b.wzyx(); // a is now 4, 3, 2, 1;你也可以使用诸如float4 a = b.rrrr();获得同样的效果,但混用颜色通道和坐标通道不可以,即不可以写成 float4 a = b.rryy();)。如果从凹凸映射采样的颜色等于(0.5,0.5,1),则normalFromBumpMap为(0,1,0),这意味着法线不偏离(都朝上)。如果bumpMapColor = (0.145,0.5,0.855),那么normalFromBumpMap为(-0.71,0.71,0)这意味着法线会向左偏离45度。 一旦知道了每个像素的法线,就可以在点乘法线和光线方向之前归一化(使之长度为1)这两者法线和光线方向。如教程5-6解释的那样,点乘的结果表示当前像素的亮度,然后将这个亮度乘以颜色。 定义Technique 最后在fx文件中添加technique定义: technique SimpleBumpMapping { pass Pass0 { VertexShader = compile vs_2_0 SBMVertexShader(); PixelShader = compile ps_2_0 SBMPixelShader(); } } 代码 把以上三部分添加到fx文件就是完整代码了。下面是设置参数的XNA代码,绘制了两个三角形: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); //set effect parameters effect.CurrentTechnique = effect.Techniques["SimpleBumpMapping"]; effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xTexture"].SetValue(myTexture); effect.Parameters["xBumpMap"].SetValue(myBumpMap); effect.Parameters["xLightDirection"].SetValue(lightDirection); //render two triangles effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 2); pass.End(); } effect.End(); base.Draw(gameTime); }

处理顶点——创建自己的顶点格式

clock 二月 14, 2011 13:52 by author alex
问题 顶点用来存储从XNA项目发送到显卡的数据。一个顶点格式包含存储在顶点中的数据的描述。XNA框架自带有默认的顶点格式,从简单的VertexPositionColor到 VertexPostionNormalTexture格式。 但是,如果你需要顶点带有额外的数据,例如切线或时间数据,就需要定义自己的顶点格式。如果要在顶点shader中使用这个数据,这一步是必须的。所以,只有编写自定义的顶点和像素shader时,你才需要定义一个自定义顶点格式。 解决方案 顶点格式定义了在顶点中存储何种类型的数据,何种数据可以被vertex shader访问。例如,当使用VertexPositionColor格式的顶点时,VertexPositionColor结构告知显卡顶点将包含位置和颜色数据,以及在数据流中的何处可以找到这些数据。 顶点格式是XNA代码和vertex shader之间的链接。首先,顶点格式表示数据在数据流中如何定位,如图5-24上面的灰色曲线所示,表明位置和颜色数据位于数据流的何处。 只要显卡需要从顶点中绘制三角形,它就需要将数据流还原为顶点。 然后,显卡需要将每个顶点分离成位置和颜色数据。所以顶点格式还告知显卡在哪切割数据流,如图5-24下面的灰色曲线所示。 最后,对每个重建的顶点,使用位置和颜色数据调用vertex shader。 图5-24 顶点格式表示在数据流中可以找到哪些数据。 工作原理 本教程中,你将创建自己的VertexPositionColor结构学习基础知识。在第二部分,你会将这个结构扩展为一个新的,自定义的顶点格式。 重新创建VertexPositionColor结构 这部分你将创建一个MyVertexPositionColor结构(和VertexPositionColor结构相同)学习基础知识。 VertexPositionColor结构能够储存所有必要的信息。它由三部分组成: 每个顶点自身的数据,为一个Vector3存储位置和一个Color 一个顶点的大小,这样显卡可以将数据流切割成单独的顶点 VertexElements,它可以告知显卡每个顶点包含何种数据,显卡应该如何切割顶点以获得对应的数据。 第1步 首先添加这个结构: public struct MyVertexPositionColor { public Vector3 Position; public Color Color; public MyVertexPositionColor(Vector3 position, Color color) { Position = position; Color = color; } } 这个结构可以存储一个表示位置的Vector3和颜色。这足以创建一个顶点并将他们发送到显卡。 第2步 需要知道一个顶点要占据多少字节,这样显卡才能完美地将数据流切割成独立的顶点。在结构中添加如下代码行: public static readonly int SizeInBytes = sizeof(float) * (3 + 1); 因为这个信息对所有顶点来说都是一样的,而且只需读取它,所以可以将它设置为static readonly。 每个顶点包含一个Vector3和一个Color。Vector3由三个浮点数组成,Color为一个浮点数。所以,一个顶点占据的字节大小为(一个浮点数占据的字节大小)*(3+1)。 注意:一个浮点数为4个字节,所以你可以将这个值表示成4*(3+1) = 16。看图5-24验证一下每个顶点都包含16的字节,每个新顶点开始的字节序号都是16的倍数。这就是显卡将字节流分割成独立的顶点的方式。 通过第二步,显卡就可以知道如何将字节流切割成独立的顶点。 第3步 最后,在结构中添加以下代码: public static readonly VertexElement[] VertexElements = { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default,VertexElementUsage.Position, 0), new VertexElement(0, sizeof(float)*3, VertexElementFormat.Color, VertexElementMethod.Default, VertexElementUsage.Color, 0), }; 上面的信息显示包含在一个顶点中的数据类型以及数据位于顶点的哪个字节中。第一个VertexElement 表示每个顶点包含位置数据,第二个表示颜色数据。让我们依次讨论每个参数。 第一个参数表示到哪个数据流获取数据。只有高级程序会使用多个顶点数据流,所以通常为0。 技巧:多个顶点流是很有用的,如果Position数据保持不变而Color数据需要频繁更新时,可以将 数据分在两个顶点流中,Position数据保持不变,你只需将Color数据从CPU传递到GPU中即可。 第二个参数表示到哪找到数据。它是顶点中的数据的字节索引。因为Position数据是第一个数据,你指定在索引0位置找到它。Color数据在Position数据之后,所以你必须知道Position数据有几个字节。Position数据占据三个浮点数,所以你指定为sizeof(float)*3 (因为一个浮点数为4个字节,也可以用12表示)。看一下图5-27:Color数据在字节12处开始。 第三个参数表示保存到数据流中的数据格式,它是数据的基类型。对Position而言是Vector3,对Color而言是Color类型。 第四个参数只用于高级和特定的硬件扩展,例如在N-Patching中,一个几何体的三角形数量会基于法线进行调整,这样会提高几何体的阴影质量。 第五个参数非常重要,它表示这个数据链接到哪个vertex shader的输入。再看一次图5-24,注意vertex shader的输入参数。这两个参数后面都跟有一个语义POSITION0和COLOR0。在本例中,你将数据的第一部分,包含Position数据,和POSITION0语义链接在一起。数据的第二部分链接到COLOR0语义。 最后一个参数让你指定每个语义的多个版本。它实际上指向POSITION0和COLOR0最后的0。对应的例子会在本教程的后半部分展示。 通过第三步,显卡知道了到哪获取顶点中的什么数据。在本例中,它会将0到11的字节 (3个浮点数)传递到vertex shader的POSITION0输入,12到15字节(1个浮点数)传递到COLOR0输入。 完整的MyVertexPositionColor结构 下面是现阶段的成果: public struct MyVertexPositionColor { public Vector3 Position; public Color Color; public MyVertexPositionColor(Vector3 position, Color color) { Position = position; Color = color; } public static readonly VertexElement[] VertexElements = { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, sizeof(float)*3, VertexElementFormat.Color, VertexElementMethod.Default, ~ VertexElementUsage.Color, 0), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 1); } MyVertexPositionColor结构的用法 有了这个结构,你就可以定义顶点并将它们转换到一个VertexBuffer流中,可见教程5-4的解释。但这次,你用的是自定义的格式: private void InitVertices() { myVertexDeclaration = new VertexDeclaration(device, MyVertexPositionColor.VertexElements); MyVertexPositionColor[] vertices = new MyVertexPositionColor[3]; int i = 0; vertices[i++] = new MyVertexPositionColor(new Vector3(1, 1, -1), Color.Red); vertices[i++] = new MyVertexPositionColor(new Vector3(3, 5, -1), Color.Green); vertices[i++] = new MyVertexPositionColor(new Vector3(5, 1, -1), Color.Blue); vertBuffer = new VertexBuffer(device, MyVertexPositionColor.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); vertBuffer.SetData<MyVertexPositionColor>(vertices, 0, vertices.Length); } 第一行代码创建VertexDeclaration,它会被传递到显卡中。 中间部分的代码创建一个包含三个MyVertexPositionColors的数组,定义了一个三角形。在每个顶点中国存储了位置和颜色。要基于这个数组创建一个VertexBuffer,你还需指定一个顶点占据的字节数,所以传递了MyVertexPositionColor.SizeInBytes为参数。 下面的代码是从VertexBuffer绘制三角形,解释可见教程5-4: basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.Vertices[0].SetSource(vertBuffer, 0, MyVertexPositionColor.SizeInBytes); device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1); pass.End(); } basicEffect.End(); 在绘制三角形前,你需要将VertexElements传递给显卡,这样显卡才知道如何正确地将字节流分割成有用的数据。 自定义顶点格式 第二个例子,你将创建一个新的顶点格式,它可以存储位置,纹理坐标和一个附加的 Vector4。这可以让你在每个顶点中存储四个额外的值,图5-25显示了字节流中的这样两个顶点。 图5-25 包含两个MyCustomVertexFormats的字节流 这是一个新结构,你仍要定义三个主要部分: public struct MyCustomVertexFormat { public Vector3 Position; public Vector2 TexCoords; public Vector4 Extra; public MyCustomVertexFormat(Vector3 Position, Vector2 TexCoords, Vector4 Extra) { this.Position = Position; this.TexCoords = TexCoords; this.Extra = Extra; } public static readonly VertexElement[] VertexElements = { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, sizeof(float)*3, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, sizeof(float)*(3+2), VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 4); } 第一部分代码允许每个顶点存储一个Position,一个Texture坐标和一个Vector4。 接下来,将它们连接到vertex shader输入。第一个Vector3链接到POSITION0 。因为这是第一个数据项目,所以可以字节0位置找到它(第二个参数)。 第二行代码表明包含纹理坐标的Vector2链接到TEXCOORD0。Position占据三个浮点数,所以纹理坐标可以在字节sizeof(float)*3 = 12处找到。 第三行将Vector4链接到另一个TEXTURE语义,因为这可以用来传递额外的数据。因为已经用过TEXTURE0,所以你将最后一个参数设为1表明将链接到TEXTURE1。 Vector4位于Position和Texture坐标数据之后,所以Vector4可以在字节sizeof(float)*(3+2) = 20处找到,可参见图5-25。 最后,一个顶点占据了总共sizeof(float)*(3+2+4) = 36个字节(其中Position三个浮点数,纹理坐标两个浮点数,Vector4四个浮点数)。 定义自定义格式的顶点 下面的代码创建了VertexDeclaration和包含三个自定义格式的顶点的VertexBuffer: myVertexDeclaration = new VertexDeclaration(device, MyCustomVertexFormat.VertexElements); MyCustomVertexFormat[] vertices = new MyCustomVertexFormat[3]; int i = 0; vertices[i++] = new MyCustomVertexFormat(new Vector3(1, 1, -1), new Vector2(0,1), new Vector4(-1.0f,0.5f,0.3f,0.2f)); vertices[i++] = new MyCustomVertexFormat(new Vector3(3, 5, -1), new Vector2(0.5f, 0), new Vector4(0.8f, -0.2f, 0.5f, -0.2f)); vertices[i++] = new MyCustomVertexFormat(new Vector3(5, 1, -1), new Vector2(1, 1), new Vector4(2.0f, 0.6f, -1.0f, 0.4f)); vertBuffer = new VertexBuffer(device, MyCustomVertexFormat.SizeInBytes vertices.Length, ResourceUsage.WriteOnly); vertBuffer.SetData<MyCustomVertexFormat>(vertices, 0, vertices.Length, SetDataOptions.None); 每个顶点需要一个Vector3,一个Vector2和一个Vector4。 定义一个可以使用自定义顶点格式的Vertex Shader 现在vertex shader可以从POSITION0, TEXCOORD0和TEXCOORD1接受数据,它们可以由以下代码访问到: // Technique: CustomVertexShader CVVertexToPixel CVVertexShader(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { CVVertexToPixel Output = (CVVertexToPixel)0; float4 origPos = float4(inPos, 1); float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(origPos, preWorldViewProjection); Output.Extra = sin(xTime*inExtra.xyz); Output.TexCoord = inTexCoord; Output.TexCoord.x += sin(xTime)*inExtra.w; Output.TexCoord.y -= inExtra.w; return Output; } 如何使用额外的输入其实并不重要,重要的是让它们在vertex shader中可用。本例中首先将3D位置映射到2D屏幕坐标。然后使用Vector4的前三个浮点数作为正弦函数的频率调制因子,并将结构存储在output 结构中。最后纹理坐标被移动,而Vector4的最后一个浮点数用来调整移动的强度。 下面是pixel shader,它使用移动的纹理坐标采样纹理,并将Extra变量中的三个值添加到颜色通道内: CVPixelToFrame CVPixelShader(CVVertexToPixel PSIn) : COLOR0 { CVPixelToFrame Output = (CVPixelToFrame)0; Output.Color = tex2D(TextureSampler, PSIn.TexCoord); Output.Color.rgb += PSIn.Extra.rgb; return Output; } 从顶点绘制这个代码设置effect变量并从顶点绘制三角形: effect.Parameters["xWorld"].SetValue(Matrix.Identity); effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); effect.Parameters["xTexture"].SetValue(texture); effect.Parameters["xTime"].SetValue(time); effect.Begin(); foreach (EffectPass pass in effect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.Vertices[0].SetSource(vertBuffer, 0, MyCustomVertexFormat.SizeInBytes); device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1); pass.End(); } effect.End(); 代码 XNA代码已经在前面写过了,下面的代码是HLSL文件的内容: float4x4 xWorld; float4x4 xView; float4x4 xProjection; float xTime; Texture xTexture; sampler TextureSampler = sampler_state { texture = <xTexture> ; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = mirror; AddressV = mirror; }; struct CVVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float3 Extra : TEXCOORD1; }; struct CVPixelToFrame { float4 Color : COLOR0; }; // Technique: CustomVertexShader CVVertexToPixel CVVertexShader(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { CVVertexToPixel Output = (CVVertexToPixel)0; float4 origPos = float4(inPos, 1); float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(origPos, preWorldViewProjection); Output.Extra = sin(xTime*inExtra.xyz); Output.TexCoord = inTexCoord; Output.TexCoord.x += sin(xTime)*inExtra.w; Output.TexCoord.y -= sin(xTime)*inExtra.w; return Output; } CVPixelToFrame CVPixelShader(CVVertexToPixel PSIn) : COLOR0 { CVPixelToFrame Output = (CVPixelToFrame)0; Output.Color = tex2D(TextureSampler, PSIn.TexCoord); Output.Color.rgb += PSIn.Extra.rgb; return Output; } technique CustomVertexShader { pass Pass0 { VertexShader = compile vs_1_1 CVVertexShader(); PixelShader = compile ps_2_0 CVPixelShader(); } }

处理顶点——从XML文件加载数据

clock 二月 14, 2011 13:48 by author alex
问题 你想从一个XML文件中加载数据到XNA项目。你可以使用默认的.NET文件IO功能在XNA项目启动时读取文件做到这点,但这在Xbox360平台上无法工作。 你想使用内容管道将一个XML文件串行化为一个二进制文件,这样就可以在XNA项目中读取包含在这些文件中的内容了。 解决方案 在XML文件中,只需简单地将你想要加载的对象插入到<XNAContent>和<Asset>标签之间,下面的XML示例文件是一个自定义的MapData类对象: <?xml version="1.0" encoding="utf-8"?> <XnaContent?> <Asset Type="XMLDataPLine.MapData"?> <mapName>Battle In The Middle</mapName> <numberOfCastles>8</numberOfCastles> <allies> <Item>Humans</Item> <Item>Elves</Item> <Item>Dwarves</Item> </allies> </Asset> </XnaContent> 技巧:如果你不知道如何从一个对象自动创建一个XML文件,你可以在本教程的最后学到方法。 XNA框架自带有内容导入器可以将XML文件转换为定义在XML文件中的对象。 这里因为对象已近建立了,所以你无需使用处理器,对象可以立即串行化为一个二进制文件。这里对象是自定义的MapData类,所以你需要定义一个自定义的TypeWriter和TypeReader。 注意:如果内容管道知道如何串行化/反串行化XML文件描述的对象,你就没必要编写一个新的TypeWriter或TypeReader。 图5-25显示了简图。 图5-25 当从XML文件导入对象时无需处理器。 工作原理 将一个. Xml文件导入到XNA项目中。在解决方案浏览器中,选择一个文件,它的属性显示在屏幕右下角。表面你想使用默认的XML内容导入器,选择No Processing Required表示导入器的输出不使用处理器就被串行化为一个文件。图5-26显示了最终的属性窗口。 图5-26 一个导入的XML文件的属性 如果XML文件包含一个内容管道知道如何串行化/反串行化的类对象,你可以将这个对象在LoadContent方法中加载到一个变量。但是本教程中是一个自定义类,所以你需要定义一个TypeWriter。 按照教程4-15中解释的步骤在解决方案中添加一个内容管道。这次,你无需定义处理器,只需定义MapData类 及对应的TypeWriter和TypeReader。 注意:确保添加内容管道项目的引用,解释请见教程4-15。这是必需的,这样你的主程序才可以访问到MapData类的定义,这个类定义是存储在内容管道项目中的。 定义自定义的MapData类 本例中的MapData类包含一个string,一个int和一个string的集合,将这个定义添加到内容管道项目中: public class MapData { public string mapName; public int numberOfCastles; public List<string> allies = new List<string>(); } 技巧:验证一下包含在前面的XML文件中的数据提供了这三个变量的数据。 定义一个可以串行化MapData类对象的TypeWriter 如教程4-15所述,TypeWriter需要从对象串行化足够的数据,这样以后对象才能被TypeReader重建。和以往一样,仍需要提供 TypeReader的位置: [ContentTypeWriter] public class MapDataTypeWriter : ContentTypeWriter<MapData> { protected override void Write(ContentWriter output, MapData value) { output.WriteObject<string>(value.mapName); output.WriteObject<int>(value.numberOfCastles); output.WriteObject<List<string>>(value.allies); } public override string GetRuntimeReader(TargetPlatform targetPlatform) { return typeof(MapDataReader).AssemblyQualifiedName; } 你表明了这个TypeWriter可以串行化MapData类对象。默认内容管道知道如何串行化一个string,一个int和一个List,所以你只需简单地将它们串行化为一个二进制文件。你传递了一个MapDataReader TypeReader的链接,下面就会定义这个链接。 定义一个可以串行化MapData类对象的TypeReader TypeReader只是简单地创建了一个新MapData对象,读取string,int和List (按正确地顺序!),并将它们存储在MapData对象中。这个对象被返回并发送到XNA游戏项目。 class MapDataReader : ContentTypeReader<MapData> { protected override MapData Read(ContentReader input, MapData existingInstance) { MapData map = new MapData(); map.mapName = input.ReadObject<string>(); map.numberOfCastles = input.ReadObject<int>(); map.allies = input.ReadObject<List<string>>(); return map; } } 使用方法 LoadContent方法的第一行代码就可以实时从MapData对象中读取数据,第二行代码只是在代码中添加一个断点,这样让你可以检查数据是否正确: protected override void LoadContent() { MapData loadedMap = Content.Load<MapData>("data"); System.Diagnostics.Debugger.Break(); } 注意:只要编译项目,XML文件就会被转换为一个二进制文件。当项目开始时,只调用了TypeReader从二进制文件构建了MapData对象。这意味着如果你改变了XML的内容,你必须重新编译。 代码 自定义内容管道只包含类定义,TypeWriter和TypeReader,这在教程前面已经写过了。 扩展阅读 从一个已存在的对象创建一个XNA可用的XML文件 本节解释了如何以一个XML文件存储任意类对象,然后你就可以使用默认XML导入器加载它了。首先需要添加下面的命名空间: using System.Xml; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate; 你还需添加System. XML和Microsoft. XNA. Framework. Content. Pipeline的引用;你可以通过从Project菜单中选择Add Reference添加这些引用。 然后,要么链接到自定义内容管道,要么通过将下列代码放在项目命名空间的外面手动重定义MapData类。 namespace XMLDataPLine { public class MapData { public string mapName; public int numberOfCastles; public List<string> allies = new List<string>(); } } 如果你手动重定义类,请确保将它放在与自定义内容管道相同的命名空间中(我教程中叫做XMLDataPLine),所有的变量也是相同的。 接下来,回到XNA项目的命名空间,确保已近有了一个MapData类对象: XMLDataPLine.MapData myMap = new XMLDataPLine.MapData(); myMap.mapName = "Battle In The Middle"; myMap.numberOfCastles = 8; myMap.allies.Add("Humans"); myMap.allies.Add("Elves"); myMap.allies.Add("Dwarves"); 然后使用这个代码存储到一个XNA可用的XML文件中,叫做data . xml: string fileName = "data.xml"; XmlWriter writer = XmlWriter.Create(fileName); IntermediateSerializer.Serialize<XMLDataPLine.MapData>(writer, myMap, fileName); writer.Close(); 当你运行程序时,data . xml文件会被创建到与. exe相同的位置中。 注意:在示例中你也能找到这个代码。

处理顶点——计算光标与地形的碰撞点:表面拾取

clock 二月 9, 2011 09:12 by author alex
问题 你想获取地形上由光标指示的位置的精确3D坐标。 解决方案 如教程4-19的介绍中讨论的那样,通过光标指示的屏幕上的一个2D点对应3D场景中的一条射线。在本教程中,我们将沿着这条射线直到它与地形发生碰撞。 你可以使用一个二分法搜索(binary search algorithm)做到这点,这可以根据你选择的精度获取碰撞位置。 对高低起伏的地形来说,可能在射线和地形之间有多个碰撞点,如图5-20所示。所以,在二分法搜索之前需要进行线性搜索,以保证检测到的碰撞是最靠近相机的。 工作原理 下面的这个方法将光标的2D屏幕位置转换为一个3D射线,这已经在教程4-19的第一部分介绍过了。 private Ray GetPointerRay(Vector2 pointerPosition) { Vector3 nearScreenPoint = new Vector3(pointerPosition.X, pointerPosition.Y, 0); Vector3 farScreenPoint = new Vector3(pointerPosition.X, pointerPosition.Y, 1); Vector3 near3DWorldPoint = device.Viewport.Unproject(nearScreenPoint, moveCam.ProjectionMatrix, moveCam.ViewMatrix, Matrix.Identity); Vector3 far3DWorldPoint = device.Viewport.Unproject(farScreenPoint, moveCam.ProjectionMatrix, moveCam.ViewMatrix, Matrix.Identity); Vector3 pointerRayDirection = far3DWorldPoint - near3DWorldPoint; Ray pointerRay = new Ray(near3DWorldPoint, pointerRayDirection); return pointerRay; } 注意:本教程中你没有归一化射线的方向,我等会儿会解释这个问题。 二分法搜索 射线包含一个起点(本例中就是射线与近裁平面的交点)和方向。射线和地形如图5-19所示。起点用A表示。射线的方向为A和B之间的向量。二分法搜索的思路很直观、首先从A、B两点开始,你应确保碰撞点位于这两点之间。计算出A、B的中点,在图5-19中用1表示。你检查这个点在地形之上还是之下。如果这个点在地形的上方(比如本图中的情景),那你就知道了碰撞点位于1和B之间。 图5-19 使用二分法搜索检测射线和地形的碰撞 然后继续找到1和B之间的点2,这个点位于地形之下,所以你知道碰撞点位于1和2之间。 继续缩小搜索范围。看一下位于1和2之间的点3,这个点在地形之上,所以碰撞点位于3和2之间。 接着,3和2之间的点4在地形之下,所以碰撞点在3和4之间。 最后,检查位于3和4之间的点5。你发现点5的高度非常接近地形上(X,Z)位置的高度,所以你找了碰撞点! 找到点A和点B 在开始二分法搜索之前,你需要从射线上的两个点A、B开始,必须确保碰撞点位于两点之间。 你可以安全地使用射线的起点作为A点,因为这是射线上最接近与相机的点(它位于近裁平面上,可见教程4-19)。 现在,可以取射线与远裁平面的交点作为B点,这个主意不错,因为这是相机内可见的射线上的最远点。 如教程4-19的GetPointerRay方法中解释的那样,pointerRay. Direction等于从A指向B的向量。 二分法搜索方法 现在知道了点A和指向B的方向,就可以检测与地形的碰撞了二分法搜索算法前面已经解释过了,翻译成伪代码如下: 只要当前点和在其之下的地形间的高度差过大,就执行以下操作: 将射线方向一分为二。 将结果方向添加到当前点获取下一个点。 如果下一个点仍在地形上方,将这个点作为当前点。 可以把这个步骤想象成在射线上行走,在放脚之前,你要检查脚是否位于地形下方。如果不是,则将步幅缩小一半继续。一但脚位于地形上方,你要及时撤回脚将它向下移动一半,直到脚位于地形之上与地形接触。 下面是代码: private Vector3 BinarySearch(Ray ray) { float accuracy = 0.01f; float heightAtStartingPoint = terrain.GetExactHeightAt(ray.Position.X, -ray.Position.Z); float currentError = ray.Position.Y - heightAtStartingPoint; while (currentError > accuracy) { ray.Direction /= 2.0f; Vector3 nextPoint = ray.Position + ray.Direction; float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); if (nextPoint.Y > heightAtNextPoint) { ray.Position = nextPoint; currentError = ray.Position.Y - heightAtNextPoint; } } return ray.Position; } 首先计算射线上的开始点与地形的高度差。 当这个高度差小于你预设的0.01的accuracy才跳出while循环。如果高度差仍太大,你要将步幅减半并计算射线上的下一个点。如果下一个点在地形之上,你要计算这个点的高度差。如果下一个点位于地形之上,则不作任何操作,这样下一次循环步幅再次减半。 跳出while循环后,ray. Position将会包含射线上与地形的高度差小于0.01的点的位置。 这个方法需要首先有GetPointerRay 方法创建的AB射线: pointerPos = BinarySearch(pointerRay); 注意:如果你的光标不在地形上,这个方法会永远停留在while循环中。 那么,如果计数器大于某个值就跳出while循环就很有用了,这会在后面的代码中讲到。 二分法搜索的问题 大多数情况中,二分法搜索做得很好,但在某些情况中会失败,如图5-20所示。 图5-20 二分法搜索会出问题的情况 因为二分法搜索不会检测点0和1之间的地形高度,导致射线与第一个山头的碰撞不会被检测到,会返回同样的结果(点5)作为射线和地形间的碰撞。 要解决这个问题,在二分法搜素之前应进行线性搜索,线性搜素相对来说比较简单。 线性搜索 在线性搜索中,你需要将射线分割成相同长度的几部分,例如,分成8段,如图5-21所示。 图5-21 线性搜索 你只是简单地以相同的步幅沿着射线前进,直到碰到一个位于地形下方的点。这不会给出一个精确的结果,但至少你检测到了射线与第一个山头的碰撞。 因为图5-21中的点1和点2都没有足够接近地形,所以需要使用二分法搜索找到点1和2之间的精确碰撞点。 LinearSearch方法以AB间的射线为参数,将这根射线分成相同的线段,返回对应哪段发生碰撞的一部分射线: private Ray LinearSearch(Ray ray) { ray.Direction /= 300.0f; Vector3 nextPoint = ray.Position + ray.Direction; float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); while (heightAtNextPoint < nextPoint.Y) { ray.Position = nextPoint; nextPoint = ray.Position + ray.Direction; heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); } return ray; } 本例中,射线被分成不超过300段。增加这个值会增加检测到的可能性但需要更多处理能力。 对每个点,你计算了下一点并检测下一个点在地形之上还是之下。如果在地形之上,则继续。如果下一个点在地形之下,返回包含碰撞前的点和碰撞发生段信息的当前射线。 返回的这条射线用在了BinarySearch方法中: Ray shorterRay = LinearSearch(pointerRay); pointerPos = BinarySearch(shorterRay); 指定程序的优化 线性搜索让你可以检测到小的山峰,而接下来的二分法搜索精度更高。 在前面的代码中,你取射线与近裁平面和远裁平面的交点作为A点和B点。这工作良好,但是如果近裁平面和远裁平面间的距离很大,射线也会很大。这意味着你要进行很多无用的检查。 作为替代,你只需考虑可能发生碰撞的高度范围内的射线。在本书中用到的地形最大高度为30,最低为0。因此,更好的做法是在射线上找到Y坐标为30的点并将它作为起始点A。然后找到将射线上Y坐标为0的点作为终点B点。 这根射线由下面的方法构建: private Ray ClipRay(Ray ray, float highest, float lowest) { Vector3 oldStartPoint = ray.Position; float factorH = -(oldStartPoint.Y-highest) / ray.Direction.Y; Vector3 pointA = oldStartPoint + factorH * ray.Direction; float factorL = -(oldStartPoint.Y-lowest) / ray.Direction.Y; Vector3 pointB = oldStartPoint + factorL * ray.Direction; Vector3 newDirection = pointB - pointA; return new Ray(pointA, newDirection); } 要找到射线上指定Y坐标的点,需要找到这个Y坐标和射线起始点的Y坐标之差。如果知道射线方向上的高度差,你就知道了需要将射线的哪一部分(存储在factor中)添加到起始点上以到达你要求的Y值的点。 注意:oldStartPoint的Y坐标是正的,当射线下降时direction的Y坐标是负的。你想让factor为一个正值,所以前面有个 – 号。 如果从输出的射线开始,在线性搜索中你只需进行少得多的检测就可以达到同样的检测率。 代码 你可以使用下面的代码检测光标射线与地形的碰撞点: protected override void Update(GameTime gameTime) { GamePadState gamePadState = GamePad.GetState(PlayerIndex.One); if (gamePadState.Buttons.Back == ButtonState.Pressed) this.Exit(); MouseState mouseState = Mouse.GetState(); KeyboardState keyState = Keyboard.GetState(); Vector2 pointerScreenPos = new Vector2(mouseState.X, mouseState.Y); Ray pointerRay = GetPointerRay(pointerScreenPos); Ray clippedRay = ClipRay(pointerRay, 30, 0); Ray shorterRay = LinearSearch(clippedRay); pointerPos = BinarySearch(shorterRay); base.Update(gameTime); } 这个代码首先调用ClipRay方法返回Y坐标为0到30之间的一段射线: private Ray ClipRay(Ray ray, float highest, float lowest) { Vector3 oldStartPoint = ray.Position; float factorH = -(oldStartPoint.Y-highest) / ray.Direction.Y; Vector3 pointA = oldStartPoint + factorH * ray.Direction; float factorL = -(oldStartPoint.Y-lowest) / ray.Direction.Y; Vector3 pointB = oldStartPoint + factorL * ray.Direction; Vector3 newDirection = pointB - pointA; return new Ray(pointA, newDirection); } 然后调用LinearSearch方法将一条很长的射线优化为一个短射线,这个射线包含最接近相机的碰撞点: private Ray LinearSearch(Ray ray) { ray.Direction /= 50.0f; Vector3 nextPoint = ray.Position + ray.Direction; float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); while (heightAtNextPoint < nextPoint.Y) { ray.Position = nextPoint; nextPoint = ray.Position + ray.Direction; heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); } return ray; } 要精确找到碰撞的3D位置,要在这条短射线上调用BinarySearch方法: private Vector3 BinarySearch(Ray ray) { float accuracy = 0.01f; float heightAtStartingPoint = terrain.GetExactHeightAt(ray.Position.X,-ray.Position.Z); float currentError = ray.Position.Y - heightAtStartingPoint; int counter = 0; while (currentError > accuracy) { ray.Direction /= 2.0f; Vector3 nextPoint = ray.Position + ray.Direction; float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z); if (nextPoint.Y > heightAtNextPoint) { ray.Position = nextPoint; currentError = ray.Position.Y - heightAtNextPoint; } if (counter++ == 1000) break; } return ray.Position; } 注意:如果while循环进行了1,000次,代码会跳出这个循环。

处理顶点——使用双线性插值计算地形的精确高度

clock 一月 28, 2011 11:04 by author alex
问题 当你制作一个使用地形的游戏时,你需要知道地形确定点的精确高度。例如,在地形上移动一个模型时(见教程4-17),当检测到光标和地形之间的碰撞时(下一个教程),或防止相机与地形碰撞时(见教程2-6)。 因为在前一个教程中你定义了地形每个顶点的3D位置,所以获取这些点的高度很简单。对位于这些顶点间的位置而言,你需要使用一种插值方法获取这个位置的精确高度。 解决方案 如果你想知道高度的点与地形的一个顶点发生碰撞,那么你已经知道了地形上该点的精确高度。如果这个点并没有与顶点发生碰撞,那么说明这个点在地形的一个三角形上。因为三角形是一个平面,所以你可以通过在三角形三个顶点间进行插值获取任何点的精确高度。 工作原理 首先从X和Z坐标开始,你想知道该点的对应Y坐标,这可以通过对三角形三个顶点的高度进行插值获取。 这意味着你首先要找到点究竟在哪个三角形中,这并不容易。但首先我想介绍插值。 线性插值 如果你只处理分离的数据、想知道分离点之间的某些值,需要用到某种类型的插值。这种情况如图5-17坐标所示。对某些分离的(整数) X值,你知道Y值。当X=2,你知道Y=10,X=3时Y=30。但你不知道X=2.7时的Y值。 图5-17 线性插值:简单常规的例子 使用线性插值,你通过连接两点的线段找到X=2.7对应的Y值,如图5-17所示。使用线性插值,通过连接两点的线段找到X=2.7对应的Y值。线性插值总是将X表达成0和1之间,0对应X的最小值(你知道对应的Y值,本例中为2),1对应X的最大值(本例中为3) 。本例中你想找到X=2.7时的Y值,结果是0.7,意思是“2和3至之间的70%。” 在图5-17的左图中,0%对应Y值10,100%对应Y值20,所以70%对应Y=17。这很容易看出,但右图的情况如何?14对应0.33,因为它是13和16之间的33%。但35和46之间的33%是多少?显然,你希望有代码可以为你计算结果。 首先要有代码找到0和1对应的值。从X值开始,首先减去最小的X值,这样最小值变为0。然后,将最大值缩放为1,你可以通过将它除以最大值和最小值之差实现。 下面是图5-17左图的做法: 2.7→(2.7-min)/(max-min)=(2.7-2)/(3-2)=0.7 然后,进行逆运算获取对应的Y值:首先缩放这个值(通过乘以最大值和最小值的差值),并加到最小的Y值上: 0.7* (maxY-min Y)+minY=0.7*(20-10)+10=0.7*10+10=17 这里你采取图5-17左图简单例子的规则,但你可以使用这个方法计算任何线性插值。看一下图5-17右图更难的例子,在这种情况中,你知道X=13对应Y=35,X=16对应Y=46,但你想知道X=14对应的Y值。所以,首先获取0和1之间对应的值: 14→(14-minX)/(maxX-minX) =(14-13)/(16-13)=0.33 知道了对应值,就做好了获取对应Y值的准备: 0.33* (maxY-minY)+minY=0.33*(46-35)+35=0.33*11+35=3.67+35=38.67 最后,需要进行浮点计算。图5-17的右图中找到X=14对应Y=38.67。事实上,几乎所有插值计算都返回一个浮点数。 技巧:XNA提供了一个功能可以为你计算Vector2, Vector3或Vector4的插值。例如,如果你想获取哪个Vector2位于(5,8)和(2,9)之间的70%,你可以使用Vector2. Lerp(new Vector2(5,8), new Vector2(2,9), 0.7f)。 双线性插值 在地形的例子中,对所有(X,Z)值,你已经定义了一个顶点并知道了它的精确高度。对所有在这些独立顶点之间的(X,Z)值,你不知道精确的Y值,所以需要进行插值。这次你需要获取0和1之间的值,包含X和Z。 有了这些值,就可以分两步计算出精确的Y值。 获取对应值 给定任意(X,Z)坐标,你需要找到地形上的精确Y高度。首先使用前面的公式找到对应的X和Z值,但这次因为在3为空间中,你需要用两次: int xLower = (int)xCoord; int xHigher = xLower + 1; float xRelative = (xCoord - xLower) / ((float)xHigher - (float)xLower); int zLower = (int)zCoord; int zHigher = zLower + 1; float zRelative = (zCoord - zLower) / ((float)zHigher- (float)zLower); 在地形中每个X和Z的整数值你定义了一个顶点,所以你知道精确的Y值。所以对每个X的浮点数,你要将它们转换为整数获取最小的X值(例如,2.7变为2)。将这个值加1获取最大X值(2.7对应3作为最大值)。知道了边界,很容易使用前面的公式找到对应值。Z值的求法类似。 获取minY和maxY的值 知道了0和1之间的对应值,下一步是找到精确Y值。但是,首先需要知道minY和maxY值。这些值表示顶点中的高度。你需要知道点在哪个三角形中才能知道使用哪个顶点的高度作为Y值。 你知道点P的X和Z坐标,所以你知道点周围的四个顶点,很容易获取它们的Y值: float heightLxLz = heightData[xLower, zLower]; float heightLxHz = heightData[xLower, zHigher]; float heightHxLz = heightData[xHigher, zLower]; float heightHxHz = heightData[xHigher, zHigher]; LxHz表示“低X坐标,高Z坐标” 决定(X,Z)。 点在哪个三角形中用来绘制地形的两个三角形。有两个方式可以定义这两个三角形,如图5-18所示。绘制三角形的方式影响到P点的高度,如图所示。 图5-18 从四个顶点绘制两个三角形的两种方法 虽然四个顶点有相同的坐标,但两种情况中的点的高度并不相同,图中你可以可出明显的区别。 基于我即将讨论的理由,更好的方式是图5-18的右图。 使用这个旋转方式,很容易确定点在哪个三角形上方。两个三角形之间的边界由对角线给出。在右图中,如果xRelative + zRelative 为1的话,这条线对应具有X和Z坐标的点。 例如,如果这个点在四个点中央,如图5-18所示,xRelative和zRelative都是0.5f,所以和为1,说明在对角线上。如果这个点偏向左边一点,xRelative会小一些,和会小于1,对Z坐标也是类似的情况。所以如果和小于1,(X,Z)坐标位于左下角的三角形内;否则,该点在右上角的三角形内: bool pointAboveLowerTriangle = (xRelative + zRelative < 1); 注意:图5-16中定义的所有三角形都是以图5-18右图中的形式绘制的。 获取精确高度 知道了对应高度,四个周围顶点的高度和点位于哪个三角形中,你就可以计算精确高度了。 如果点在左下方的三角形中,这时pointAboveLowerTriangle为true,下面是使用双线性插值获取三角形任意点高度的代码: finalHeight = heightLxLz; finalHeight += zRelative * (heightLxHz - heightLxLz); finalHeight += xRelative * (heightHxLz - heightLxLz); 根据前面解释的单插值的方法,从lowestX的Y值开始。因为这是“双”插值,你要从lowestXlowestZ的Y值开始。 在单插值中,你maxY之间添加高度差,并乘以对应的X值。在双插值中,你乘的是 zRelative和xRelative。 换句话说,从左下顶点的高度开始,对这个高度,你添加了这个顶点和有着更高Z坐标的顶点间的高度差,并乘以距离第二个顶点的Z坐标的接近程度。最后一行代码类似:对这个高度,你添加了左下顶点和右下顶点的高度差,乘以距离右下顶点的X坐标的接近程度。 如果该点在右上三角形的内部,这时pointAboveLowerTriangle为false,情况有所不同,你需要以下代码: finalHeight = heightHxHz; finalHeight += (1.0f - zDifference) *(heightHxLz - heightHxHz); finalHeight += (1.0f - xDifference) * (heightLxHz - heightHxHz); 从高度开始,从右上顶点开始,遵循同样的步骤:添加高度差,乘以对应距离。 代码 这个方法包含前面解释的所有代码。基于任意(X,Z)坐标,无论是整数还是浮点数,这个方法返回该点的精确高度。首先检查该点是否在地形上。如果不是,返回默认的高度10。 public float GetExactHeightAt(float xCoord, float zCoord) { bool invalid = xCoord < 0; invalid |= zCoord < 0; invalid |= xCoord > heightData.GetLength(0) - 1; invalid |= zCoord > heightData.GetLength(1) - 1; if (invalid) return 10; int xLower = (int)xCoord; int xHigher = xLower + 1; float xRelative = (xCoord - xLower) / ((float)xHigher - (float)xLower); int zLower = (int)zCoord; int zHigher = zLower + 1; float zRelative = (zCoord - zLower) / ((float)zHigher - (float)zLower); float heightLxLz = heightData[xLower, zLower]; float heightLxHz = heightData[xLower, zHigher]; float heightHxLz = heightData[xHigher, zLower]; float heightHxHz = heightData[xHigher, zHigher]; bool pointAboveLowerTriangle = (xRelative + zRelative < 1); float finalHeight; if (pointAboveLowerTriangle ) { finalHeight = heightLxLz; finalHeight += zRelative * (heightLxHz - heightLxLz); finalHeight += xRelative * (heightHxLz - heightLxLz); } else { finalHeight = heightHxHz; finalHeight += (1.0f - zRelative) * (heightHxLz - heightHxHz); finalHeight += (1.0f - xRelative) * (heightLxHz - heightHxHz); } return finalHeight; }

处理顶点——基于一个顶点缓冲和一个索引缓冲创建一个地形

clock 一月 28, 2011 11:01 by author alex
问题 基于一张2D高度图,你想创建一个地形并以一个有效率的方法绘制它。 解决方案 首先需要一张高度图,包含所有用来定义地形的高度数据。这张高度图有确定数量的二维数据点,我们称之为地形的宽和高。 显然,如果你想基于这些数据创建一个地形,这个地形将从这些width*height顶点中绘制,如图5-14右上角所示(注意数字是从0开始的)。 图5-14 地形网格 要使用三角形完全覆盖网格,你需要在每个网格的四个点中绘制两个三角形,如图5-14所示。一行需要(width-1)*2个三角形,整个地形需要(height-1)*(width-1)*2个三角形。 如果你想判断是否需要使用索引,可参见教程5-3的规则。本教程的情况中,(三角形数量) 除以(顶点数)小于1,所以应该使用索引。所有不在边界上的顶点会被不少于六个的三角形共享。 此外,因为所有三角形至少共享一条边,你应该使用TriangleStrip而不是TriangleList。 工作原理 定义顶点 首先定义顶点。下面的方法首先访问heightData变量,这个变量是一个包含地形所有顶点高度的2维数组。如果你还没有这样一个数组,本教程最后的LoadHeightData方法会基于一张2D图像创建一个。 private VertexPositionNormalTexture[] CreateTerrainVertices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height]; int i = 0; for (int z = 0; z < height; z++) { for (int x = 0; x < width; x++) { Vector3 position = new Vector3(x, heightData[x, z], -z); Vector3 normal = new Vector3(0, 0, 1); Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f); terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord); } } return terrainVertices; } 首先基于heightData数组的大小获取地形的高和宽。然后创建一个数组保存所有顶点。如前所述,地形需要width*height个顶点。 接着在两个循环中国创建所有顶点。里面的一个循环创建一行上的顶点,当一行完成后,第一个for循环切换到下一行,直到定义完所有行的顶点。 你使用X和Z坐标作为循环的计数器,z值是负的,因此地形是建立在向前(-Z)方向的。而高度信息取自heightData数组。 现在你给与所有顶点一个默认的法线方向,这个方向马上就会使用前一个教程的方法替换成正确的方向。因为你可能要在地形上加上纹理,所以需要指定正确的纹理坐标。根据纹理,你想控制它在地形上的大小。这个例子中将除以30,表示纹理每经过30个顶点重复一次。如果你想增大纹理匹配更大的地形,可以除以一个更大的数值。 有了这些数据,就做好了创建这些新顶点并存储到数组中的准备。 定义索引 定义了顶点后,你就做好了通过定义索引数组构建三角形的准备(见教程5-3)。你将以TriangleStrip定义三角形,基于一个索引及它前两个索引表示一个三角形。 图5-16显示了如何使用TriangleStrip绘制三角形。数组中的第一个索引指向顶点0和W。然后对行中的每个顶点,添加这个顶点和下一行对应的顶点,直到到达行一行的最后。。这时,你要定义2*width个索引,对应(2*width-2)个三角形,足够覆盖整个行。 但是你只定义了第一行,你没法使用这个方法绘制第二行,这是因为你是基于前三个索引定义的三角形添加的每个索引的。基于这点,你定义的最后一个索引指向顶点(2*W-1)。如果你再次从第二行开始,会从添加一个到顶点W的索引开始,如图5-15左图所示。但是,这回定义一个基于顶点W, (2*W-1)和(W-1)的三角形!这个三角形会横跨第一行的整个长度,这不是你想要的结果。 图5-15 使用TriangleStrip定义三角形的错误方式 你可以通过从右边开始定义第二行解决这个问题。但是,简单地从最后一个索引开始不是一个好主意,因为两行的三角形的长边有不同的方向,如教程5-9中的解释,你想让三角形有相同的朝向。 图5-16显示了如何解决这个问题。在指向顶点(2*W-1)的索引后,你将立即添加一个指向相同顶点的索引!这会添加一个基于顶点(W-1)和两个顶点(2*W-1)的三角形,只会形成一条位于顶点(W-1)和(2*W-1)之间的一条线,所以这个三角形不可见,叫做ghost三角形。接下来,添加一个指向顶点(3*W-1)的索引,因为这个三角形基于两个指向相同顶点(2*W-1)的索引,所以实际上是一条线。如果你从右边开始定义第二行,正常情况下你会从两个顶点开始,记住实际上你绘制了两个看不见的三角形。 图5-16 使用TriangleStrip定义三角形的正确方式 注意:你可能认为无需添加第二个指向(2*W-1)的索引,可以立即将一个索引添加到(3*W-1)中。但是,基于两个理由需要额外的指向顶点(2*W-1)的索引。首先,如果你没有添加这个索引,那么只有一个三角形被添加,你会被TriangleStrip方式所需的绕行方向的反转所干扰。第二,这会添加一个基于(3*W-1), (2*W-1)和 (W-1)的三角形,如果三个顶点高度不相同那么这个三角形会被显示。 下面是生成索引的方法: private int[] CreateTerrainIndices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); int[] terrainIndices = new int[(width)*2*(height-1)]; int i = 0; int z = 0; while (z < height-1) { for (int x = 0; x < width; x++) { terrainIndices[i++] = x + z * width; terrainIndices[i++] = x + (z + 1) * width; } z++; if (z < height-1) { for (int x = width - 1; x >= 0; x--) { terrainIndices[i++] = x + (z + 1) * width; terrainIndices[i++] = x + z * width; } } z++; } return terrainIndices; } 首先创建一个数组,存储地形所需的所有索引。如教程5-16所示,每行需要定义width*2个三角形。在本例中,你有三行顶点,但只绘制两行三角形,所以需要width*2*(height-1) 索引。 前面代码中的z值表示当前行。你从左向右创建第一行,然后,增加z,表示切换到下一行。第二行从右向左创建,如图5-16所示,z值仍然增加。这个程序放在while循环中,直到所有偶数行从左向右建立,奇数行从右向左建立。 当z变为height-1时while循环结束,返回结果数组。 法线,顶点缓冲和索引缓冲 你要创建法线数据,通过创建一个顶点缓冲和一个索引缓冲将这些数据发送到显卡,然后绘制三角形。 在LoadContents方法中添加以下代码: myVertexDeclaration=new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices(); int[] terrainIndices = CreateTerrainIndices(); terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices); CreateBuffers(terrainVertices, terrainIndices); 第一行代码用来告知显卡每个顶点包含位置,法线和纹理坐标的数据。我已近讨论过下面两个方法:它们生成所有顶点和索引。GenerateNormalsForTriangleStrip方法在教程5-7,它将法线数据添加到顶点中使地形光照正确。最后的方法将数据发送到显卡: private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[] indices) { terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length,BufferUsage.WriteOnly); terrainVertexBuffer.SetData(vertices); terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly); terrainIndexBuffer.SetData(indices); } 你可以在教程5-3中找到所有方法和使用参数的解释。 把数据发送到显卡后,现在可以绘制地形了。代码的第一部分设置BasicEffect (包含光照,见教程6-1)的变量,所以在Draw方法中添加以下代码: int width = heightData.GetLength(0); int height = heightData.GetLength(1); basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = grassTexture; basicEffect.TextureEnabled = true; basicEffect.EnableDefaultLighting(); basicEffect.DirectionalLight0.Direction =new Vector3(1, -1, 1); basicEffect.DirectionalLight0.Enabled = true; basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f); basicEffect.DirectionalLight1.Enabled = false; basicEffect.DirectionalLight2.Enabled = false; basicEffect.SpecularColor = new Vector3(0, 0, 0); 要将一个3D场景绘制到2D屏幕上,需要设置World,View和Projection矩阵(见教程2-1和4-2)。然后指定纹理。第二个代码块设置一个定向光(如教程6-1所示)。关闭镜面高光(见教程6-4),这是因为草地地形没有闪亮的材质。 设置了effect后就可以绘制三角形了。这个代码从一个索引TriangleStrip绘制三角形,解释请见教程5-3: basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer, 0,VertexPositionNormalTexture.SizeInBytes); device.Indices = terrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, width * 2 * (height - 1) - 2); pass.End(); } basicEffect.End(); 首先将VertexBuffer和IndexBuffer作为显卡的当前缓冲。VertexDeclaration表明GPU需要何种数据,在数据流中的哪儿获取必要信息。DrawIndexedPrimitives绘制TriangleStrip,这需要处理所有width*height个顶点,绘制总共width*2*(height-1)-2个三角形。要获取最后一个值,需要查询索引数组中的索引总数。因为你是从一个TriangleStrip进行绘制的,所以顶点的总数为这个值减2。 代码 LoadContent方法中的最后四行代码生成所有索引和对应的顶点。法线数据被添加到顶点,最终的数据存储在顶点缓冲和索引缓冲中。注意LoadHeightMap方法会在后面讨论: protected override void LoadContent() { device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); cCross = new CoordCross(device); Texture2D heightMap = Content.Load<Texture2D>("heightmap"); heightData = LoadHeightData(heightMap); grassTexture = Content.Load<Texture2D>("grass"); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices(); int[] terrainIndices = CreateTerrainIndices(); terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices); CreateBuffers(terrainVertices, terrainIndices); } 在下列方法中创建顶点: private VertexPositionNormalTexture[] CreateTerrainVertices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height]; int i = 0; for (int z = 0; z < height; z++) { for (int x = 0; x < width; x++) { Vector3 position = new Vector3(x, heightData[x, z], -z); Vector3 normal = new Vector3(0, 0, 1); Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f); terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord); } } return terrainVertices; } 在下列方法中创建索引: private int[] CreateTerrainIndices() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); int[] terrainIndices = new int[(width) * 2 * (height - 1)]; int i = 0; int z = 0; while (z < height - 1) { for (int x = 0; x < width; x++) { terrainIndices[i++] = x + z * width; terrainIndices[i++] = x + (z + 1) * width; } if (z < height - 1) { for (int x = width - 1; x >= 0; x--) { terrainIndices[i++] = x + (z + 1) * width; terrainIndices[i++] = x + z * width; } } z++; } return terrainIndices; } GenerateNormalsForTriangleStrip方法将法线数据添加到顶点中,而CreateBuffers方法 将数据储存到显卡中: private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[]indices) { terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); terrainVertexBuffer.SetData(vertices); terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly); terrainIndexBuffer.SetData(indices); } 最后,在Draw方法中地形以TriangleStrip方式绘制: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); //draw terrain int width = heightData.GetLength(0); int height = heightData.GetLength(1); basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = grassTexture; basicEffect.TextureEnabled = true; basicEffect.EnableDefaultLighting(); basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 1); basicEffect.DirectionalLight0.Enabled = true; basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f); basicEffect.DirectionalLight1.Enabled = false; basicEffect.DirectionalLight2.Enabled = false; basicEffect.SpecularColor = new Vector3(0, 0, 0); basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer,0, VertexPositionNormalTexture.SizeInBytes); device.Indices = terrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height,0, width * 2 * (height - 1) - 2); pass.End(); } basicEffect.End(); base.Draw(gameTime); } 从一张图像读取heightData数组 大多数情况中,你并不想手动地指定heightData数组,而是从一张图像加载。这个方法加载一张图像,将每个像素的颜色映射为高度值: private void LoadHeightData(Texture2D heightMap) { float minimumHeight = 255; float maximumHeight = 0; int width = heightMap.Width; int height = heightMap.Height; Color[] heightMapColors = new Color[width * height]; heightMap.GetData<Color>(heightMapColors); heightData = new float[width, height]; for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) { heightData[x, y] = heightMapColors[x + y * width].R; if (heightData[x, y] < minimumHeight) minimumHeight = heightData[x, y]; if (heightData[x, y] > maximumHeight) maximumHeight = heightData[x, y]; } for (int x = 0; x < width; x++) for (int y = 0; y < height; y++) heightData[x, y] = (heightData[x, y] - minimumHeight) / (maximumHeight - minimumHeight) * 30.0f; } 第一部分将每个像素的红色通道的强度存储在heightData数组中。后面的代码重新缩放每个值,这样可以让数组中的值介于0到30的范围中。

处理顶点——自动计算顶点缓冲中所有顶点的法线

clock 一月 28, 2011 10:57 by author alex
问题 当绘制自定义的结构时,你会发现光照不正确。 这是因为你没有指定正确的法线向量,显卡要求每个顶点都有法线信息,这样它才可以决定每个三角形获得多少光照,详细信息可见第六章。 为每个顶点计算法线向量看起来很复杂,因为大多数顶点被多个三角形共享。 解决方案 如果每个顶点只被一个三角形使用,你只需找到三角形的法线向量(换句话说,这个向量垂直于三角形)并将这个向量作为三个顶点的法线向量。 但是在一个结构中,所有顶点被几个三角形共享。要获取平滑的效果,每个顶点需要存储周围三角形所有法线的平均值。 工作原理 使用下列伪代码,你可以找到每个顶点正确的法线: 对于结构中的每个顶点,找到使用顶点的三角形。 计算这些三角形的法线向量。 求所有法线向量的平均值。 将这个平均法线存储到顶点中。 求平均值的过程是必须的,因为你总是要归一化存储在顶点中的法线向量(换句话说,让长度变为1)。 注意:因为在vertex和pixel shader中要使用法线向量计算光线因子,所以要让法线向量长度变为1。当光线因子只由入射光和法线之间的夹角决定时,一个较大的法线向量会导致光线因子变大,具体解释可见第六章。 你可以将上述步骤变为具体代码,但如果你交换步骤1和2,会变得更容易: 对于结构中的每个三角形,计算法线向量。 将这个向量添加到三角形的三个顶点的法线中。对所有三角形进行这个操作后,执行以下操作: 归一化结构中的每个顶点的法线向量。 计算三角形的法线:叉乘的定义 在计算前,你需要知道什么是法线。简单的说,法线是垂直于三角形的方向,这意味着三角形上的任一点法线都是相同的。因为法线垂直于三角形平面,所以它也垂直于三角形的任何一个顶点。 那么如何计算一个三角形的法线向量呢?你可以使用叉乘,因为两个向量的叉乘返回垂直于两个向量决定的平面的向量。 你可以取三角形的两条边,通过叉乘获取垂直这个三角形的向量,如图5-13所示。这个法线的长度基于两条边的长度和夹角,所以需要将法线向量归一化。 图5-13 获取三角形的法线向量 注意:Vector3. Cross (Vec1, Vec2)和Vector3. Cross (Vec2,Vec1)计算结果是不同的。这两个结果长度相同但方向相反。这是因为一个平面有两个垂直方向:一个指向纸外,一个指向纸内。 GenerateNormalsForTriangleList方法 当定义一个大对象时,你通常希望使用索引定义结构,因为这是顶点被多个三角形共享的唯一方式,这让你可以平滑光照(见教程6-2)。所以这个教程基于结构中的顶点数组和索引数组进行计算: Private VertexPositionNormalTexture[] GenerateNormalsForTriangleList(VertexPositionNormalTexture[] vertices, int[] indices) { } 这个方法接受一个不包含法线数据顶点数组,然后将正确的法线信息存储在每个顶点中并返回这个数组。根据索引信息,这个方法可以判断哪些顶点构成了三角形。但是,根据使用的是TriangleList还是TriangleStrip,索引数组的内容会不同,所以针对两者情况代码会有一些区别。 基于TriangleList计算法线 如果顶点已经包含法线数据,首先要将它们变为0: for (int i = 0; i < vertices.Length; i++) vertices[i].Normal = new Vector3(0, 0, 0); 然后,如前面的伪代码所示,你要遍历所有三角形并计算它们的法线。在TriangleList中,每个三角形由三个连续的索引定义。这意味着三角形的数量为Length/3。下面是遍历定义在索引数组中的每个三角形的循环代码: for (int i = 0; i < indices.Length/3; i++) { Vector3 firstVec = vertices[indices[i*3 + 1]].Position - vertices[indices[i*3]].Position; Vector3 secondVec = vertices[indices[i*3 + 2]].Position- vertices[indices[i*3]].Position; Vector3 normal = Vector3.Cross(secondVec, firstVec); normal.Normalize(); vertices[indices[i*3]].Normal += normal; vertices[indices[i*3 + 1]].Normal += normal; vertices[indices[i*3 + 2]].Normal += normal; } 对每个三角形,你定义了两个如图5-13所示的向量。点P0和P1之间的向量可以通过将P1减P0获得,这是第一行代码。第二行代码计算从P0指向P2的向量。 然后,通过将两者叉乘获取垂直于这两个向量的向量,别忘了归一化结果让它的长度变为1。 注意:根据你定义索引的方式,你需要使用Vector3. Cross (secondVec, firstVec); 代替。前面已经提到过,这会获取一个相反方向的矢量。如果沿顺时针方向定义顶点(见教程5-6), 代码会工作正常。 知道了三角形的法线,只需简单地将它添加到每个顶点中。当for循环完成后,每个顶点就存储了三角形的所有法线的和。然后要通过归一化将这些大向量的长度变为1。 for (int i = 0; i < vertices.Length; i++) vertices[i].Normal.Normalize(); return vertices; 把所有法线存储到顶点数组后,将这个数组返回到调用代码。 基于TriangleStrip计算法线 对TriangleStrip来说情况有点不同,因为创建索引数组中的每个索引都是基于它和前两个索引创建一个三角形的: for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position-vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position-vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); } 从第三个索引开始,你遇到的每个索引基于索引i, i-1和i-2创建了一个三角形。前面的代码遍历了由索引数组定义的所有三角形并创建了两个对应三角形两边的向量。 但是,你以TriangleStrip方式定义索引时,每个三角形后会自动反转旋转顺序(见教程5-1的注意事项)。结果是,firstVec和secondVec会改变位置,在Cross方法中改变firstVec和secondVec的位置会达到同样效果,每个三角形后都会反转法线的方向。 你无法改变这个反转,但可以解决这个问题。只需建立一个Boolean变量,每个三角形后就反转这个值。如果这个值为true,你就改变法线的方向: bool swappedWinding = false; for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position - vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position -vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); if (swappedWinding) normal *= -1; vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; swappedWinding = !swappedWinding; } 代码的其他部分与前面类似,别忘了代码最前面的将初始法线复位到0和最后的归一化法线的代码。 使方法Fail-Safe 如果firstVec 和secondVec向量方向相同,Vector3 . Cross方法会发生错误。这种情况下三角形会变成一条线,被称之为ghost三角形(见教程5-8的例子)。 这种情况下,Vector3 . Cross会返回包含的三个NaN值的Vector3。如果发生这种情况,那么就不要将这个向量添加到顶点,否则会报错: if (!float.IsNaN(normal.X)) { vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; } 从顶点缓冲和索引缓冲开始 前面的代码从包含顶点和索引的两个数组开始。如果你已经将它们存储在显存的缓冲中(见教程5-4)并保存了一个本地复制,你需要将这些数据取回来。下面是方法: int numberOfVertices = myVertexBuffer.SizeInBytes / VertexPositionNormalTexture.SizeInBytes; VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[numberOfVertices]; myVertexBuffer.GetData(vertices); int numberOfIndices = myIndexBuffer.SizeInBytes / 4; int[] indices = new int[numberOfIndices]; myIndexBuffer.GetData(indices); 你通过查看顶点缓冲占据多少个字节获取缓冲中的顶点数量,因为你知道一个顶点占据多数字节,所以可以知道在顶点缓冲中有多少个顶点。 同样的方法可以找到索引缓冲中的索引数量,因为你知道一个索引时4个字节。 注意:如教程5-4中的解释,并不推荐在顶点缓冲或索引缓冲上使用GetData。而且如果你使用的是BufferUsage. WriteOnly标志创建缓冲,编译器会对GetData方法报错。 代码 下面的代码将法线数据存储到一个顶点数组中,而且索引是以TriangleStrip方式绘制三角形的: private VertexPositionNormalTexture[] GenerateNormalsForTriangleStrip(VertexPositionNormalTexture[] vertices, int[] indices) { for (int i = 0; i < vertices.Length; i++) vertices[i].Normal = new Vector3(0, 0, 0); bool swappedWinding = false; for (int i = 2; i < indices.Length; i++) { Vector3 firstVec = vertices[indices[i - 1]].Position- vertices[indices[i]].Position; Vector3 secondVec = vertices[indices[i - 2]].Position - vertices[indices[i]].Position; Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize(); if (swappedWinding) normal *= -1; if (!float.IsNaN(normal.X)) { vertices[indices[i]].Normal += normal; vertices[indices[i - 1]].Normal += normal; vertices[indices[i - 2]].Normal += normal; } swappedWinding = !swappedWinding; } for (int i = 0; i < vertices.Length; i++) vertices[i].Normal.Normalize(); return vertices; }

处理顶点——开启Backface Culling(背面剔除):它是什么和它能做什么

clock 一月 26, 2011 10:50 by author alex
问题 在前面的教程中,你经常可以在Draw方法中看到以下代码: device.RenderState.CullMode = CullMode.None; 如果你移除这行代码并将相机移动到三角形的后面,它们会消失!当你在没有理解backface culling时定义三角形顶点,会有50%可能三角形无法显示。 解决方案 你定义三角形顶点的方式告知XNA你想让哪一面朝向相机。 当绘制一个实心对象时,人们可以清楚地说出三角形的哪个面在物体的内部还是外部。要绘制必须的三角形,你可以要求XNA只绘制朝向相机的那个面,其他三角形在物体的内部被前面隐藏! 但是对计算机来说事情不是那么简单。对你定义的每个三角形,你需要说明三角形是在外面还是里面,这可以通过沿逆时针方向或顺时针方向定义三角形的顶点做到这点。 工作原理 当定义顶点时,你需要考虑相机的位置。要绘制三角形,顶点的顺序必须以相机看来按顺时针方向递增。 你可以想象一条从相机出发指向三角形中心的射线,如图5-9所示。如果顶点的顺序(从相机看过来!)是绕着这个中心点顺时针旋转的(左图),XNA就认为这个三角形是面向相机的,这样,三角形才会被绘制。 图5-9 位于三角形前方(左图)和后方(右图)的相机 如果你将相机放在同一个三角形的另一侧(如图5-9右图所示),顶点的顺序(同样是从相机看过来)是逆时针方向,XNA会认为这个三角形没有面向相机而不绘制它。 图5-10显示了相同的情况,但这次是从相机的角度观察,显然在右图中相机看到的是逆时针顺序。 图5-10 顺时针定义的三角形会被绘制(左图);而逆时针定义的会被剔除。 为什么需要背面剔除? 只有一个三角形的例子可以说明原理但无法说明使用背面剔除的好处。本教程的前面曾经提到,在绘制实心对象时背面剔除很有用,例如一个模型或地形。 让我们讨论一个立方体的例子。当你旋转一个立方体时,在任何时刻你只能看到三个面。这意味着绘制另外三个面对显卡的处理能力来说是一种浪费,没有朝向相机的三角形没必要绘制。 想象一下拿着一个立方体并只能看到一个面如图5-11的左边所示。然后看一下右图,前表面和后表面的各一个三角形加粗显示。 数字代表顶点的顺序。由12个三角形组成的六个面,需要定义36个顶点。图5-11中的数字表示哪个顶点定义了两个三角形。 注意:如果你使用索引,立方体只需要8个独立顶点,但仍需要36个索引才能绘制从8个顶点绘制12个三角形。那么图5-11中的数字代表哪个索引定义两个三角形。 图5-11 立方体:前表面和后表面的两个三角形 图5-12的左图显示了一根从相机出发的射线并与两个三角形相交,圆点表示射线和前面的三角形的交点。当你沿着前面的三角形的顶点增加的顺序(从0到1到2)前进时,会发现绕着圆点做顺时针旋转,这时XNA会绘制这个三角形。 现在看一下射线与后面的三角形的交点,当你沿着顶点增加的顺序(从6到7到8)前进时,会绕逆时针旋转,这时XNA会将这个三角形剔除,这样处理很好,因为这个三角形位于立方体的后方,被前表面隐藏了。 图5-12的右图显示了同样的立方体,但旋转了180度,这样前表面和后表面就互换了位置,相机仍位于页面的同一侧。现在如果顺着顶点6到7到8,将绕视线做顺时针旋转,这次,显卡会绘制这个三角形并剔除另一个!这正是我们想要的结果。 通过这种方式,显卡知道应该剔除哪些三角形,剔除那些不朝向相机的三角形可以极大地提高程序的性能,这也是默认激活的。 图5-12 从眼睛发出的射线与两个三角形的交点 技巧:教程5-7有一个圆柱体的例子,确保剔除是开启的,试着将相机移动到圆柱体上方,看一下结果(译者注:看不到圆柱体的上表面)! 如何关闭剔除 虽然当绘制由实心表面组成的对象时使用剔除可以带来极大地好处,但有时你也需要将提出关闭。例如,你想创建一个只由两个三角形组成的长方形墙,从外部看效果不错,但当你进入到建筑物内部,这面墙会被剔除,导致你可以看穿这堵墙! 要解决这个问题,你可以在墙的反面定义两个额外的三角形,这样无论从那一面观察这堵墙,总有两个三角形被绘制,另两个被剔除。更简单的方法是使用下列代码将剔除关闭: device.RenderState.CullMode = CullMode.None; 别忘了在绘制墙后再将剔除打开。 技巧:当设计特别是调试程序时,你可以将剔除关闭,这样做可以排除物体没有被绘制的一个可能性。 代码 示例代码定义了一个沿顺时针和逆时针方向的立方体,当运行逆时针版本时,你将看到XNA只绘制了那些无需被绘制的三角形。 当运行顺时针版本时,试着将相机移动到立方体内部。

处理顶点——将经常更新的顶点数据存储在DynamicVertexBuffer

clock 一月 26, 2011 10:47 by author alex
问题 你需要频繁更新顶点数据,如果使用VertexBuffer的SetData方法会拖慢程序。 解决方案 如果你计划频繁更新顶点数据,应该使用DynamicVertexBuffer而不是VertexBuffer。 这会让数据不是存储在最快的显存中,而是更容易处理的某些内存中。所以,这样做会让性能有一点降低,但在VertexBuffer中频繁地改变数据会让性能得到极大地提高。 对于VertexBuffer,只要显卡被要求切换任务(例如,使用Alt+Tab切换程序),DynamicVertexBuffer中的内容必须被重载,你可以通过订阅到DynamicVertexBuffer的ContentLost事件上实现这个功能。 工作原理 DynamicVertexBuffer的工作原理与VertexBuffer很像。首先需要一个顶点数组,如下所示: private void InitVertices() { myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); vertices = new VertexPositionTexture[6]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, -3, -1), new Vector2(-0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(1.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(-0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(1.5f, 1.5f)); dynVertBuffer = new DynamicVertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); dynVertBuffer.SetData(vertices, 0, vertices.Length, SetDataOptions.NoOverwrite); dynVertBuffer.ContentLost +=new EventHandler(dynVertBuffer_ContentLost); } 创建DynamicVertexBuffer接受和创建VertexBuffer一样的参数,但SetData方法接受一个新的加强的参数,我会在下面讲到。最后一行代码是新的,如果显卡被要求处理另一个程序,设备会丢失,DynamicVertexBuffer的内容也会丢失。所以你要在DynamicVertexBuffer 的ContentLost事件上订阅一个方法。只要DynamicVertexBuffer丢失了内容就会引发ContentLost 事件,对应的方法就会被调用,这个方法会重载DynamicVertexBuffer中的内容。本例中,你将dynVertBuffer_ContentLost方法订阅到事件。 当然你还要定义这个方法,在这个方法中重载缓冲的内容: private void dynVertBuffer_ContentLost(object sender, EventArgs e) { dynVertBuffer.SetData(vertices, 0, vertices.Length, SetDataOptions.NoOverwrite); } 注意:这需要本地处理顶点数据。因为只有当顶点数据变化地很频繁时才使用DynamixVertexBuffer,所以首先要保证数据没有问题,记住你永远不会从(Dynamic)VertexBuffer中读取数据。 当进行到绘制这步时,DynamicVertexBuffer的工作方式与VertexBuffer是相同的。 性能考虑:DynamicVertexBuffer.SetData方法 因为当你想频繁更新数据时才会使用DynamicVertexBuffer,所以你会经常使用SetData 方法。因此DynamicVertexBuffer的SetData方法还接受一个额外的参数SetDataOptions。 这个参数让你可以指定一些选项可以提高程序的速度。默认情况下,当你想覆盖显存中的内容时,因为不支持同时读写,显卡无法从显存中读取数据。当你把大量数据写至内存时会导致显卡的绘制过程中止,这是因为显卡要等待你的复制过程结束。但是,有两个方法可以让你确保显卡不会等待复制操作结束。你可以使用SetDataOptions 参数,下面是可选项: SetDataOptions. None:这个选项可以完全控制覆盖VertexBuffer 哪一部分。但是,如前面解释的那样,这会导致性能降低。如果显卡绘制的数据是从VertexBuffer 之前内容提取的,那么显卡必须停止绘制直到较慢的复制过程结束。 SetDataOptions. Discard:使用这个选项表示你不再需要VertexBuffer的之前的内容。这时数据存储在显存的一个新的位置。在写入过程发生时,显卡还能继续使用老的数据。一旦写入过程完成,显卡可以使用新的数据绘制而抛弃老数据。简而言之,显卡无需等待,但你必须重写所有数据。(在Xbox平台上无法使用这个选项,你只能调用DrawUserIndexedPrimitive绘制频繁更新的顶点。) SetDataOptions . NoOverwrite:这个选项很强大但比较危险。你必须保证正在覆盖的VertexBuffer部分没有被渲染过程使用。这样,你可以覆盖VertexBuffer 的特定部位,此时显卡无需等待复制过程结束,因为你正在覆盖的部分不参与绘制过程。这比使用Discard选项快,因为你无需保留在内存中保留一块新的部分。

处理顶点——使用顶点缓冲和索引缓冲将顶点和索引保存在显存中

clock 一月 25, 2011 09:19 by author alex
问题 每次当你调用device. DrawUserPrimitives方法时,顶点都会从系统内存传递到显卡中。通常,大部分数据没有变化,这意味着每帧重复传递了相同的数据。今天的显卡有容量很大而且很快的显存,所以你可以将顶点数据存储在显存中加速程序。从显存中将顶点数据传递到显卡速度要快得多,因为这些数据只在同一板卡的不同芯片间传输。同样索引数据也能获得加速。 解决方案 通过创建顶点数组的VertexBuffer,你可以将顶点数据复制到显存中。将顶点存储到显存后,就可以调用DrawPrimitives方法 (代替DrawUserPrimitives方法),这个方法从更快的显存中获取顶点。 注意:如果顶点几乎无需更新,那么这个方法可以极大地提高性能。但当处理被称为动态顶点数据(dynamic vertex data)时,这意味着顶点数据更新频繁,你应使用DynamicVertexBuffer,这会在下一个教程介绍。 工作原理 你可以一次性地将数据传递到显卡并把它们存储在显存中,而不是在每次调用DrawUserPrimitives方法时将顶点数组传递到显卡。要实现这个方法,你可以在创建了顶点数组后将它加载到一个VertexBuffer(顶点缓冲)中。在项目中添加VertexBuffer变量: VertexBuffer vertBuffer; 然后使用下面的方法生成六个顶点显示两个带纹理的三角形(可见教程5-1)。在这个方法最后,这六个顶点会加载到VertexBuffer并传递到显存中: private void InitVertices() { myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); VertexPositionTexture[] vertices = new VertexPositionTexture[6]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, new Vector2(-0.5f,1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, new Vector2(1.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(-0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(1.5f, 1.5f)); vertBuffer = new VertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); vertBuffer.SetData(vertices, 0, vertices.Length); } 当定义了所有顶点后,你需创建一个VertexBuffer。这个VertexBuffer将传递到显卡的保留内存中,由于这个原因,你需要指定对设备的链接和顶点占据的字节数量,这个数量等于 (一个顶点的字节大小)乘以(顶点数量)。 注意:你当然不想每帧都创建一个VertexBuffer,请确保只调用这行代码一次。如果你想覆盖VertexBuffer的数据,不要删除它并重建一个,而是应该使用SetData方法将新数据加载到当前已经存在的VertexBuffer中。只要当你需要增加顶点的数量时才有必要删除VertexBuffer 并重建一个。 创建了VertexBuffer后,你就可以使用SetData方法将顶点数据传递到内存中。显然,SetData方法需要从顶点数组中复制数据。它的一个重载方法可以只写入VertexBuffer的部分数据,在这种情况下,你需指定从哪个顶点开始以及复制多少个顶点。 注意:如果你要频繁地更新VertexBuffer的内容,你应该使用DynamicVertexBufferDynamicVertexBuffer的SetData方法更为强大。 在显卡中存储了顶点数据后就可以绘制三角形了。你可以使用教程5-1中的代码,但有两处变化。 你将使用DrawPrimitive方法表明你将从VertexBuffer中获取数据进行绘制。在这之前首先要激活VertexBuffer。 device.RenderState.CullMode = CullMode.None; basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.Vertices[0].SetSource(vertBuffer, 0, VertexPositionTexture.SizeInBytes); device.DrawPrimitives(PrimitiveType.TriangleList, 0, 2); pass.End(); } basicEffect.End(); 在pass中,与以前一样需要将VertexDeclaration传递给设备表明顶点存储的数据类型、下一行代码设置VertexBuffer。对每个顶点来说,你可以在不同的VertexBuffers中存储不同的信息。本例中,你只使用了一个VertexBuffer,因此指定索引为0。使用SetSource方法,表明你将激活vertBuffer作为顶点的源。你还要指定第一个顶点的开始位置和一个顶点占据多少个字节(这样显卡才能将字节流裁切成独立的顶点)。激活VertexBuffer后,从第一个顶点开始使用TriangleList进行绘制,这个VertexBuffer 包含两个三角形的数据。 对性能的考虑:VertexBuffer构造函数 VertexBuffer的构造函数可以在最后一个参数中指定一些有用的可选择的标志,显卡的驱动会使用这个标志决定哪种内存是最快的。下面是可以使用的BufferUsages: BufferUsage. None:允许从VertexBuffer中读写。 BufferUsage. Points:表示VertexBuffer中的顶点数据是用来绘制点和精灵的。这个与性能无关。 BufferUsage. WriteOnly:不从VertexBuffer读取数据。当使用VertexBuffer时,可以将顶点数据放在快得多的显存中。这样,当你想处理顶点数据时,比起将顶点数据在系统内存中存储一个副本,调用VertexBuffers的GetData方法往往更好。 你也可以组合这些标志,用 | 分隔表示OR操作(如果不矛盾,结果是指定选项的AND逻辑)。 IndexBuffer(索引缓冲) 如果你还想将索引存储在显卡中,你应在创建VertexBuffer之后再创建一个IndexBuffer ,所以在项目中添加一个IndexBuffer变量: IndexBuffer indexBuffer; 作为一个简单的例子,你将定义两个三角形,因为它们共享一个顶点,所以只需定义5个独立顶点: private void InitVertices() { myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); VertexPositionTexture[] vertices = new VertexPositionTexture[5]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, -3, -1), new Vector2(-0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(1.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(-0.5f, 1.5f)); vertBuffer = new VertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly); vertBuffer.SetData(vertices, 0, vertices.Length); } 接下来使用InitIndices方法创建索引数组并将它们复制到索引缓冲中: private void InitIndices() { int[] indices = new int[6]; int i = 0; indices[i++] = 0; indices[i++] = 1; indices[i++] = 2; indices[i++] = 2; indices[i++] = 3; indices[i++] = 4; indexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly); indexBuffer.SetData<int>(indices); } 创建IndexBuffer时,你需要指定索引的类型,类型可以是ints或shorts,还要指定索引的多少。 当心:一些低端显卡只支持16-bit的索引,如果你使用32-bit的整数型索引会出错。要解决这个问题你应将索引存储在一个short数组中,指定创建一个short类型的索引缓冲。 技巧:如果你的数组包含不超过32,768的索引,你应该使用shorts而不是ints。这样索引缓冲可以节省一半内存。 别忘了在项目开始调用这个方法。 当进行绘制时,你需要激活VertexBuffer和IndexBuffer,然后调用DrawIndexedPrimitives 方法绘制三角形: device.RenderState.CullMode = CullMode.None; basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.Vertices[0].SetSource(vertBuffer, 0, VertexPositionTexture.SizeInBytes); device.Indices = indexBuffer; device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 5, 0, 2); pass.End(); } basicEffect.End(); 代码 InitVertices,InitIndices和Draw方法的代码前面已经写过了。

处理顶点——使用索引移除冗余顶点

clock 一月 25, 2011 09:17 by author alex
问题 你要绘制的三角形共享了很多顶点,如图5-7所示。 图5-7 可以从使用索引中受益的结构 如图5-7所示的八个三角形在使用TriangleList的情况下需要8*3 = 24个顶点,从这个图中可以看到实际上只有9个独立的顶点,所以其余15个顶点会浪费显卡的内存,带宽和处理能力。 解决方案 好的办法是将这9个独立顶点存储在数组中并将这个数组传递到显卡。然后创建一个包含24个数字的集合,作为这9个顶点的引用。图5-8左边是包含24个顶点的大集合,右边是包含9个顶点和24个索引的小集合,注意每个索引都对应一个顶点。 图5-8 由24个顶点绘制的8个三角形(左)或24个指向9个顶点的索引(右) 这样做可以带来很大的益处。因为索引只是数字,所以相对于包含一个Vector3,一个颜色,一个纹理坐标,可能还有其他信息的顶点来说,索引是很小的。这可以节省显存和带宽。而且显卡上的vertex shader只需处理9个顶点而不是24个,当处理由上千个三角形构成的复杂结构时这样做的好处是非常明显的。 每个索引指向9个顶点之一,所以三角形实际上是基于索引绘制的。使用TriangleList时,你需要24个索引用来绘制8个三角形。 工作原理 首先你需要定义独立顶点的数组。这个数组对应图5-7中的结构: private void InitVertices() { vertices = new VertexPositionColor[9]; vertices[0]= new VertexPositionColor(new Vector3(0, 0, 0), Color.Red); vertices[1]= new VertexPositionColor(new Vector3(1, 0, 0), Color.Green); vertices[2]= new VertexPositionColor(new Vector3(2, 0, 1), Color.Blue); vertices[3]= new VertexPositionColor(new Vector3(0, 1, -1), Color.Orange); vertices[4]= new VertexPositionColor(new Vector3(1, 1, 0), Color.Olive); vertices[5]= new VertexPositionColor(new Vector3(2, 1, 0), Color.Magenta); vertices[6] = new VertexPositionColor(new Vector3(0, 2, 0), Color.Yellow); vertices[7] = new VertexPositionColor(new Vector3(1, 2, 1), Color.Tomato); vertices[8] = new VertexPositionColor(new Vector3(2, 2, -1), Color.Plum); myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements); } 现在你需要定义使用哪个顶点创建三角形,每个三角形需要三个索引。 接下来,你将创建索引集合对应这些顶点。首先添加一个数组保存这些索引: private int[] indices; 下面的方法将索引添加到新定义的数组中: private void InitIndices() { indices = new int[24]; indices[0] = 0; indices[1] = 3; indices[2] = 1; indices[3] = 1; indices[4] = 3; indices[5] = 4; indices[6] = 1; indices[7] = 4; indices[8] = 5; indices[9] = 1; indices[10] = 5; indices[11] = 2; indices[12] = 3; indices[13] = 6; indices[14] = 7; indices[15] = 3; indices[16] = 7; indices[17] = 4; indices[18] = 4; indices[19] = 7; indices[20] = 5; indices[21] = 5; indices[22] = 7; indices[23] = 8; } 我将代码按每三个索引一组分成代码块,每个块对应一个三角形,这个集合也对应图5-7中的灰色数字。例如,左上角的三角形由索引12至14定义,对应顶点3,6,7。 注意:三角形顶点是按顺时针方向定义的,要知道为什么可见教程5-6. 别忘了调用这个方法,例如在Initialize方法中: InitIndices(); 定义了顶点和索引,你就可以将这些数据发送到显卡绘制三角形了: basicEffect.World = Matrix.CreateScale(2.0f); basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.VertexColorEnabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserIndexedPrimitives<VertexPositionColor>(PrimitiveType.TriangleList, vertices, 0, 9, indices, 0, 8); pass.End(); } basicEffect.End(); 除了有两行代码,上面的代码段与教程5-1中的是相同的。第一行代码将世界矩阵缩放到2倍,表明所有顶点位置都乘以2。这样网格会从0扩展到4,关于世界矩阵更多的信息可见教程4-2。 第二第二行代码使用DrawUserIndexedPrimitives方法表示你使用索引数组绘制三角形。你需要声明顶点数组和需要绘制多少个顶点以及从哪个顶点开始绘制。接下来,需要指定包含索引的数组和从哪个索引开始绘制。最后一个参数指定绘制多少个图元,本例中图元是三角形。 注意:这个方法支持教程5-1中讨论过的所有图元类型。例如,可以使用索引绘制 TriangleStrip或LineList,但是,使用索引绘制点是毫无用处的。 何时使用索引? 使用索引不见得都能优化性能,所以在使用索引前,你应该首先不使用索引绘制三角形。 有些情况中使用索引反而会使性能降低。例如,有五个三角形并不共享一个顶点。不使用索引,你需要使用15个顶点,而使用索引,你仍要定义15个顶点,因为这些顶点是独立的!而且还要定义15个索引,每个索引对应一个顶点,这种情况下,传递到显卡的数据反而变多了! 作为一个规律,你可以将(独立顶点的数量) 除以(三角形的数量)。如果没有共享顶点,那么这个值是3,如果有共享,这个值会小于3。这个值越小,,使用索引提升的性能就越多。例如在图5-7中,这个值是9/8 = 1.125,这表示使用索引可以极大地提升性能。 代码 前面已经包含了所有的代码,这里我就不重复写了。

处理顶点——在三角形上添加纹理

clock 一月 25, 2011 09:07 by author alex
问题 你想绘制一个颜色漂亮的三角形并可以完全控制三角形上的颜色。 解决方案 你的显卡允许你指定一张图像,从这张图像可以采样想要的颜色。 这意味着你需要将一张2D图像导入到XNA项目中,并在绘制三角形之前将它传递到显卡。对每个顶点,你要指定2D图像的哪个位置对应顶点。 工作原理 首先将一张2D图像导入到项目中,如教程3-1所示。在LoadContent方法中将它链接到一个变量: myTexture = content.Load<Texture2D>("texture"); 然后你要定义三角形的顶点。如前面的教程所述,你需要指定每个顶点的3D位置。这次你无需定义颜色而是定义在2D纹理中的对应位置,这一步需要在每个顶点中存储2D纹理坐标。如果三角形的每个顶点对应一个2D纹理坐标,显卡就会将你指定的2D图像的某一部分填充到三角形内部。 当指定纹理坐标时,要记住纹理的左上为坐标(0,0),右上为(1,0)。这意味着第二个坐标值表示垂直位置,所以右下坐标为(1,1)。图5-4显示了对应不同纹理坐标的纹理区域。 图5-4 为顶点指定纹理坐标 你需要存储一个2D纹理坐标而不是每个顶点的颜色。不是存储一个包含VertexPositionColor元素的数组,而是创建一个包含VertexPositionTexture元素的数组,如这个名称所述,它将存储纹理坐标和一个3D位置: private void InitVertices() { vertices = new VertexPositionTexture[9]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-10, 1, -5), new Vector2(0,0)); vertices[i++] = new VertexPositionTexture(new Vector3(-7, 5, -5), new Vector2(0.5f, 1)); vertices[i++] = new VertexPositionTexture(new Vector3(-4, 1, -5), new Vector2(1,0)); vertices[i++] = new VertexPositionTexture(new Vector3(-3, 1, -5), new Vector2(0, 0.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -5), new Vector2(1, 0)); vertices[i++] = new VertexPositionTexture(new Vector3(3, 1, -5),new Vector2(1, 1)); vertices[i++] = new VertexPositionTexture(new Vector3(4, 1, -5), new Vector2(0.25f, 0.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(7, 5, -5), new Vector2(0.5f, 0)); vertices[i++] = new VertexPositionTexture(new Vector3(10, 1, -5), new Vector2(1, 1)); myVertexDeclaration=new VertexDeclaration(device, VertexPositionTexture.VertexElements); } 这里定义的九个顶点拥有对应图5-4中的三个三角形的纹理坐标。通过指定纹理坐标,你可以显示如图5-4所示的三角形。 注意: 三角形在3D空间中的实际位置定义为一个Vector3。例如,图5-4中的第一个三角形是倒置的,因为两个顶点的Y坐标是1,中间一个顶点的Y坐标为5,这使得三角形中间的字母XNA也是颠倒绘制的(译者注:图5-4中的第一个三角形中的XNA并没有颠倒,但在源代码中是颠倒的,怀疑作者截图有误)。 你还需要告知显卡顶点现在包含不同的信息。所以需要基于VertexPositionTexture的VertexElement创建一个VertexDeclaration,这样可以在绘制三角形前将它传递到显卡。 定义了顶点后,你就做好了绘制三个三角形的准备: device.RenderState.CullMode = CullMode.None; basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration=myVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 3); pass.End(); } basicEffect.End(); 你需要将纹理传递到显卡,这一步需要将这个纹理设置为BasicEffect的当前纹理。然后,不像前一个教程那样使用定义在顶点中的颜色,本教程通过将 TextureEnabled设置为true指定BasicEffect从纹理采样颜色。因为你使用了一个不同的顶点格式,所以需要将新的VertexDeclaration传递到显卡。 最后绘制三个三角形。 纹理寻址模式 如前所述,纹理坐标(0,0)对应纹理的左上点的像素,(1,1)对应右下点。 但这并没有限定纹理坐标的值必须在[0,1]范围内,例如设定为(1.5f, –0.5f)也可以。在这种情况中,你需要通过设置U和V(U是第一个纹理坐标,V是第二个)的纹理模式让XNA知道如何处理这样的坐标。下面的代码定义了一个U和V坐标超过[0,1]范围的三角形: private void InitVertices() { vertices = new VertexPositionTexture[3]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-3, -3, -1), new Vector2(-0.5f,1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(3, -3, -1), new Vector2(1.5f,1.5f)); myVertexDeclaration=new VertexDeclaration(device, VertexPositionTexture.VertexElements); } 然后你要通过设置纹理采样器的寻址模式让XNA知道如何处理这些坐标,这用来从纹理采样颜色。 TextureAddressMode.Clamp 使用下列代码将UV模式设置为Clamp: device.SamplerStates[0].AddressU = TextureAddressMode.Clamp; device.SamplerStates[0].AddressV = TextureAddressMode.Clamp; 这个寻址模式让所有的纹理坐标截取到[0,1]范围内,所有小于0的坐标截取到0,大于1的截取到1。 结果是,超出[0,1] 范围的纹理坐标的所有像素的颜色为纹理边缘的颜色,如图5-5所示。图中显示的三角形是前面已经定义的。 下面是显卡在从图像采样颜色前首先映射的纹理坐标的例子: (-0.5f, 1.5f) → (0, 1) (0.5f, -1.5f) → (0.5f, 0) (1.5f, 1.5f) → (1,1) 图5-5 Clamp (左)和Wrap (右)纹理寻址模式 TextureAddressMode.Wrap 这是默认的纹理寻址模式,在使用了一个不同的模式后你可以使用下列代码重新选择使用wrap模式: device.SamplerStates[0].AddressU = TextureAddressMode.Wrap; device.SamplerStates[0].AddressV = TextureAddressMode.Wrap; 使用这个模式,显卡会从坐标加上或减去1直到坐标仍回到[0,1]范围。 这会导致原始纹理被复制,如图5-5右边所示。 下面是一些纹理坐标映射的例子: (-0.5f, 1.5f) → (0.5f, 0.5f) (1.2f, -0.2f) → (0.2f,0.8f) (-0.7f, -1.2f) → (0.3f,0.8f) TextureAddressMode.Mirror 使用下列代码将纹理寻址模式设置为: device.SamplerStates[0].AddressU = TextureAddressMode.Mirror; device.SamplerStates[0].AddressV = TextureAddressMode.Mirror; 这个模式导致原始纹理被复制,但与Wrapping模式不同的是,复制的图像与相邻的原始图像是互为镜像的。复制的图像在原始图像的上方和下方是垂直镜像的,左方和右方是水平映射的。对角线上的复制沿着两边,翻转180度,如图5-6左图所示。 图5-6 Mirror (左)和MirrorOne (右)纹理寻址模式 这个模式很有用,因为可以在放大图像避免边缘锐化。 下面是一些坐标映射的例子: (-0.5f, 1.5f) → (0.5f, 0.5f) (0.5f, -1.5f) → (0.5f, 0.5f) (1.2f, 1.7f) → (0.8f, 0.3f) TextureAddressMode.MirrorOnce 你可以选择MirrorOnce模式: device.SamplerStates[0].AddressU = TextureAddressMode.MirrorOnce; device.SamplerStates[0].AddressV = TextureAddressMode.MirrorOnce; 这个模式将[-1,1] 区域内的纹理坐标镜像到[0,1]区间,而所有超出[-1,1]区间的坐标会截取到–1 (小于–1的值)或1 (大于1的值),如图5-5右边所示。 下面是采样坐标的映射例子: (-0.5f, 1.5f) → (0.5f, 1) (0.5f, -1.5f) → (0.5f, -1) (1.2f, 1.7f) →(1, 1) TextureAddressMode.Border 你可以给纹理坐标超出[0,1]区间的像素设置一个指定的颜色,这个颜色叫做 BorderColor,可以使用下列代码存储在SamplerState中: device.SamplerStates[0].BorderColor = Color.LightSeaGreen; device.SamplerStates[0].AddressU = TextureAddressMode.Border; device.SamplerStates[0].AddressV = TextureAddressMode.Border; 当调试时这个模式很有用,因为它清晰地显示了所有超出[0,1]区间的纹理坐标,所有超出此区间的像素都使用BorderColor绘制。 当心:在XBox360平台上只能使用白色作为边界颜色。 代码 首先需要定义顶点,因为需要保护3D位置和对应的纹理位置,你需要使用VertexPositionTexture elements。别忘了初始化对应的VertexDeclaration。 private void InitVertices() { vertices = new VertexPositionTexture[3]; int i = 0; vertices[i++] = new VertexPositionTexture(new Vector3(-3, -3, -1), new Vector2(-0.5f, 1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -1), new Vector2(0.5f, -1.5f)); vertices[i++] = new VertexPositionTexture(new Vector3(3, -3, -1), new Vector2(1.5f, 1.5f)); myVertexDeclaration= new VertexDeclaration(device,VertexPositionTexture.VertexElements); } 有了存储在数组中的顶点和一个有效的VertexDeclaration,你可以设置一个纹理寻址模式并绘制三角形: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); //draw triangles device.RenderState.CullMode = CullMode.None; basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = myTexture; basicEffect.TextureEnabled = true; device.SamplerStates[0].AddressU=TextureAddressMode.Mirror; device.SamplerStates[0].AddressV = TextureAddressMode.Mirror; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = myVertexDeclaration; device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 1); pass.End(); } basicEffect.End(); base.Draw(gameTime); }

友情链接赞助