赞助广告

 

年份

最新评论

评论 RSS

处理2D图像和纹理——投影纹理

clock 十二月 8, 2010 13:57 by author alex
创建一面镜子:投影纹理 问题 你想在场景中创建一面镜子。例如,在一个赛车游戏中创建一面后视镜。你也可以使用这个技术创建一个反射贴图。 解决方案 首先需要将镜子中看到的场景绘制到一张纹理中。然后,绘制相机中见到的场景(包括空镜子),最后将这张纹理贴在镜子上。 要绘制从镜中看起来的场景,你需要定义第二个相机,叫做镜像相机(mirror camera)。你可以通过镜像第一个普通相机的Position,Target和Up向量获取镜像相机的Position,Target和Up向量。当通过镜像相机观看场景时,你看到的与镜子中看到的一样。图3-24 显示了这个原理。 图3-24 镜像原理 将这个结果储存到纹理中之后,你将绘制在普通相机中看到的场景和使用投影纹理将纹理绘制到镜子上,投影纹理将正确的像素映射到镜子的对应位置。如果镜像相机和镜子之间有物体存在,这个方法不适用。 你可以通过定义一个镜像剪裁平面解决这个问题,这样镜子之后的物体都会被剪裁掉。 工作原理 首先在项目添加以下变量: RenderTarget2D renderTarget; Texture2D mirrorTexture; VertexPositionTexture[] mirrorVertices; Matrix mirrorViewMatrix; 你需要将镜子中看到的场景绘制到一个自定义的渲染目标中,所以需要renderTarget和mirrorTexture变量。要创建镜像相机,需要定义一个镜像View矩阵。因为你需要将镜子添加到场景中,所以需要一些顶点定义镜子的位置。 变量renderTarget在LoadContent方法中进行初始化。创建和使用自定义渲染目标更多的信息可参见教程3-8 。 PresentationParameters pp = device.PresentationParameters; int width = pp.BackBufferWidth; int height = pp.BackBufferHeight; renderTarget = new RenderTarget2D(device, width, height, 1, device.DisplayMode.Format); 技巧:你也可以减少渲染目标的宽和高。通过这个方式,显卡的开销更少,但镜中的图像看起来比较粗糙。 初始化渲染目标后,定义镜子的位置: private void InitMirror() { mirrorVertices = new VertexPositionTexture[4]; int i = 0; Vector3 p0 = new Vector3(-3, 0, 0); Vector3 p1 = new Vector3(-3, 6, 0); Vector3 p2 = new Vector3(6, 0, 0); Vector3 p3 = p1 + p2 - p0; mirrorVertices[i++] = new VertexPositionTexture(p0, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p1, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p2, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p3, new Vector2(0,0)); mirrorPlane = new Plane(p0, p1, p2); } 你可以使用这个方法创建任意形状的镜子,本例中,创建的是一个简单的矩形镜子。使用镜子平面显示自定义渲染目标中的内容。你需要使用TriangleStrip绘制两个三角形定义这个矩形,所以只需四个顶点。 在3D空间中,你只需三个点就可以定义一个矩形。这个方法让你可以指定镜子的顶点p0, p1和p2,代码会计算点p3,使第四个点也在同一平面内,对应的是这行代码: Vector3 p3 = p0 + (p1 – p0) + (p2 - p0); 从这四个位置创建四个顶点。这个方法无需将纹理坐标传递到(见后面的vertex shader),但是因为我懒得定义一个VertexPosition结构,所以只是简单地传递了一些任意的纹理坐标,例如(0,0),因为它们不会被用到(可参见教程5-14学习如何创建自定义顶点格式)。 注意:因为本例中所有Z坐标为0,所以镜子在XY平面中。 构建镜像相机的View矩阵 下一步,你想创建一个镜像View矩阵,用来绘制从镜子中看起来的场景。要创建这个镜像View矩阵,你需要镜像相机的Position, Target和Up向量。镜像View 矩阵的Position, Target和Up向量是普通相机的关于镜子平面的镜像,如图3-24所示。 private void UpdateMirrorViewMatrix() { Vector3 mirrorCamPosition = MirrorVector3(mirrorPlane, fpsCam.Position); Vector3 mirrorTargetPosition = MirrorVector3(mirrorPlane, fpsCam.TargetPosition); Vector3 camUpPosition = fpsCam.Position + fpsCam.UpVector; Vector3 mirrorCamUpPosition = MirrorVector3(mirrorPlane, camUpPosition); Vector3 mirrorUpVector = mirrorCamUpPosition - mirrorCamPosition; mirrorViewMatrix = Matrix.CreateLookAt(mirrorCamPosition, mirrorTargetPosition, mirrorUpVector); } Position和TargetPosition可以简单地被镜像,因为它们是在绝对3D空间中的。但是,Up向量表示一个方向,无法立即被镜像。你需要首先将Up方向转化为一个3D位置,在相机上方的某处,这可以通过将Up方向添加到相机的3D位置中做到。 因为这是一个3D位置,你可以取它的镜像了。你获取的是一个位于镜像相机上方的3D位置,所以减去镜像相机的位置就可以获取镜像相机的Up方向了。 知道了镜像相机的Position, Target和Up方向,你就可以创建View矩阵了。 MirrorVector3方法会根据传入的mirrorPlane参数对Vector3进行镜像。因为本教程创建的镜子位于XY平面,要找到镜像位置只需要改变Z分量的符号: private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3) { Vector3 mirroredV3 = originalV3; mirroredV3.Z = -mirroredV3.Z; return mirroredV3; } 等会你会学习如何关于任意平面进行镜像,但是这个方法中的数学原理会分散你的注意力。现在从Update 方法中调用UpdateMirrorViewMatrix方法: UpdateMirrorViewMatrix(); 绘制从镜中看到的场景 有了镜像View矩阵,你就可以使用这个矩阵绘制从镜子中看到的场景了。因为需要绘制场景两次(一次从镜子看到,一次从普通相机中看到),好主意是重构你的代码,将场景绘制放到一个独立的方法中: private void RenderScene(Matrix viewMatrix, Matrix projectionMatrix) { Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(0, 0, 5); myModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in myModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = viewMatrix; effect.Projection = projectionMatrix; } mesh.Draw(); } //draw other objects of your scene ... } 这个方法使用View和Projection矩阵作为参数绘制场景。 在Draw方法中,添加这个代码,开启自定义渲染目标并通过镜像View矩阵将镜子中看到的场景绘制到这个渲染目标中。之后,通过将后备缓冲设置为当前渲染目标关闭这个自定义渲染目标(见教程3-9),然后将自定义渲染目标中的内容储存到一个纹理中: //render scene as seen by mirror into render target device.SetRenderTarget(0, renderTarget); graphics.GraphicsDevice.Clear(Color.CornflowerBlue); RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix); //deactivate custom render target, and save its contents into a texture device.SetRenderTarget(0, null); mirrorTexture = renderTarget.GetTexture(); 注意:在镜像的情况中,你想使用与普通场景相同的投影矩阵绘制自定义渲染目标。如果,例如,你的普通投影矩阵的角度大于渲染目标的矩阵,在shader中计算的坐标会混合在一起。 保存了纹理后,你将清除后备缓冲,然后绘制普通相机中的场景,换句话说,使用普通 View矩阵: //render scene + mirror as seen by user to screen graphics.GraphicsDevice.Clear(Color.Tomato); RenderScene(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); RenderMirror(); 最后一行代码调用的方法将镜子添加到场景中。在本例中,镜子只是一个由两个三角形定义的简单矩形,它的颜色从包含镜子中看到的场景的纹理中采样。因为镜子要显示纹理正确部分,你不能简单地将图像放置在矩形上,而是应该创建一个HLSL technique。 HLSL 首先定义XNA-HLSL变量,纹理采样器,vertex和pixel shader的输出结构: //XNA interface float4x4 xWorld; float4x4 xView; float4x4 xProjection; float4x4 xMirrorView; //Texture Samplers Texture xMirrorTexture; sampler textureSampler = sampler_state { texture = <xMirrorTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct MirVertexToPixel { float4 Position : POSITION; float4 TexCoord : TEXCOORD0; }; struct MirPixelToFrame { float4 Color : COLOR0; } 和往常一样,你需要World, View和Projection矩阵计算每个3D顶点的2D屏幕位置。而且还需要镜像相机的View矩阵用来在vertex shader中计算镜子中每个顶点的对应纹理坐标。 Technique需要包含镜中看的的场景的纹理,它会通过采用这个纹理获取镜子中的每个像素的颜色。 vertex shader的输出结构是这个纹理坐标和当前顶点的2D屏幕坐标。而pixel shader只计算像素的颜色。 Vertex Shader 和往常一样,vertex shader计算每个顶点的2D屏幕坐标,这可以通过将3D位置乘以 WorldViewProjection矩阵做到。 //Technique: Mirror MirVertexToPixel MirrorVS(float4 inPos: POSITION0) { MirVertexToPixel Output = (MirVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); } 在镜像technique中,对镜子的每个顶点,vertex shader还要计算顶点对应xMirrorTexture中的哪个像素。比方说你想找到xMirrorTexture中的哪个像素对应镜子的左上顶点,找到答案的关键在于从镜像相机中看镜子。你需要获取镜像相机保存在哪个xMirrorTexture 中的顶点对应哪个2D坐标,这实际上就是你通过镜像相机的WorldViewProjection矩阵转换的3D坐标。 float4x4 preMirrorViewProjection = mul (xMirrorView, xProjection); float4x4 preMirrorWorldViewProjection = mul(xWorld, preMirrorViewProjection); Output.TexCoord = mul(inPos, preMirrorWorldViewProjection); return Output; 注意:代码中的单词Mirror不表示一个附加矩阵;只是表示属于镜像相机的矩阵。例如,xMirrorView不是表示Mirror矩阵乘以View矩阵;只是表示镜像相机的View矩阵。 Pixel Shader 现在在pixel shader中,对镜子的四个顶点,你有了对应的纹理坐标。接下来的问题是哪个范围是你不想要的。而你知道纹理坐标介于0和1之间,如图3-25左图所示。而屏幕坐标的范围在–1至1之间,如图3-25右图所示。 图3-25 纹理坐标(左),屏幕坐标(右) 幸运的是,从[-1,1]映射到[0,1]很简单。例如,你可以先除以2,这样范围变为[-0.5,0.5],然后加0.5,范围变为[0,1]。而且,因为你处理的是一个float4 (齐次)坐标,在使用前三个分量前,需要将它们除以第四个坐标。这就是pixel shader的第一部分进行的操作: MirPixelToFrame MirrorPS(MirVertexToPixel PSIn) : COLOR0 { MirPixelToFrame Output = (MirPixelToFrame)0; float2 ProjectedTexCoords; ProjectedTexCoords[0] = PSIn.TexCoord.x/PSIn.TexCoord.w/2.0f +0.5f; ProjectedTexCoords[1] = -PSIn.TexCoord.y/PSIn.TexCoord.w/2.0f +0.5f; Output.Color = tex2D(textureSampler, ProjectedTexCoords); return Output; } 计算第二个纹理坐标的代码中的“–”号是必须的,因为场景需要上下颠倒绘制到帧缓冲中,所以你需要进行调整。最后一行代码查询xMirrorTexture中的对应颜色,这个颜色返回到pixel shader中。 注意:前两个分量表示2D屏幕坐标;你需要将前三个分量除以第四个分量,但第三个坐标是什么?它实际上是2D深度。换句话说,这个值在显卡z缓冲中,介于0和1之间,表示顶点在近裁平面,1表示在远裁平面。当调用pixel shader计算像素颜色时,显卡首先根据像素在z缓冲中的深度值判断这个像素是否应该被绘制 。更多的信息可参见教程2-1的最后一部分。 下面是technique定义: technique Mirror { pass Pass0 { VertexShader = compile vs_1_1 MirrorVS(); PixelShader = compile ps_2_0 MirrorPS(); } } 在XNA中使用Technique 你还需要在XNA项目中定义DrawMirror方法,这个方法使用刚才创建的technique绘制矩形: private void RenderMirror() { mirrorEffect.Parameters["xWorld"].SetValue(Matrix.Identity); mirrorEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); mirrorEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); mirrorEffect.Parameters["xMirrorView"].SetValue(mirrorViewMatrix); mirrorEffect.Parameters["xMirrorTexture"].SetValue(mirrorTexture); mirrorEffect.Begin(); foreach (EffectPass pass in mirrorEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, mirrorVertices, 0, 2); pass.End(); } mirrorEffect.End(); } 设置World, View和Projection矩阵,还有xMirrorView矩阵和包含镜中看的的场景的xMirrorTexture。矩形的两个三角形以TriangleStrip的形式绘制。你需要在XNA项目导入. fx文件并将它连接到mirrorEffect变量。 任意镜像平面 在前面的例子中,选择了一个特殊位置的镜像平面,因此很容易对点进行镜像。但是在真实情况中,你还想定义任意镜像平面。这需要改进MirrorVector3方法,让它可以在任意镜像平面上对任意3D点进行镜像: private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3) { float distV3ToPlane = mirrorPlane.DotCoordinate(originalV3); Vector3 mirroredV3 = originalV3 - 2 * distV3ToPlane * mirrorPlane.Normal; return mirroredV3; } 首先你想知道点与平面间的最短距离,这可以通过镜子平面的DotCoordinate方法进行计算(这个最短距离就是点垂直于平面的距离)。如果你将平面法线乘以这个距离,从这个点减去刚才计算的结果向量,你就会位于平面上。但你不想在平面上,你想移动两倍距离!所以,你需要将这个结果向量加倍并从初始点坐标中减去这个向量。 这个代码让你可以基于任意三点使用一面镜子。 定义一个镜子剪裁平面 仍有一个大问题:当物体在镜子后面时,这些物体会被镜子相机看到并存储到mirrorTexture中。当Mirror pixel shader从这个纹理采样颜色时,这些物体会被绘制到镜子上,但实际上这些物体是在镜子之后的,不应该被显示。 解决方法是定义一个用户剪裁平面,这可以通过定义一个平面并告知XNA在镜子另一边的物体无需绘制做到。当然,这个平面应该是镜子所处平面,所以在镜子之后的物体无需绘制。 但是,剪裁平面的四个系数必须定义在剪裁空间中(这样你的显卡可以容易地判断哪些物体需要被绘制哪些需要被剪裁)。要将系数从3D空间映射到剪裁空间,你需要通过ViewProjection的反置(inverse-transpose)矩阵变换它们,如下列代码所示: private void UpdateClipPlane() { Matrix camMatrix = mirrorViewMatrix * fpsCam.ProjectionMatrix; Matrix invCamMatrix = Matrix.Invert(camMatrix); invCamMatrix = Matrix.Transpose(invCamMatrix); Vector4 mirrorPlaneCoeffs = new Vector4(mirrorPlane.Normal, mirrorPlane.D); Vector4 clipPlaneCoeffs = Vector4.Transform(-mirrorPlaneCoeffs, invCamMatrix); clipPlane = new Plane(clipPlaneCoeffs); } 首先计算反置矩阵。然后,接受镜子平面的定义在3D空间中的四个系数,通过反置矩阵将它们映射到剪裁空间,然后使用结果创建剪裁平面。 注意:“–”表示平面的哪一个面需要被剔除。与平面的法线方向有关,是根据定义点p0, p 1, p2和p3的顺序定义的。 因为clipPlane变量取决于viewMatrix,而viewMatrix在相机位置改变时需要更新,所以在Update方法中调用下面的方法: UpdateClipPlane(); 接下来要做的就是将剪裁平面传递到显卡中,在绘制镜子中看到的场景前开启它,记住在绘制普通相机看到的场景前关闭它,因为镜子后面的物体可以被普通相机看到,应该被显示: //render scene as seen by mirror into render target device.SetRenderTarget(0, renderTarget); device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0); device.ClipPlanes[0].Plane = clipPlane; device.ClipPlanes[0].IsEnabled = true; RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix); device.ClipPlanes[0].IsEnabled = false; 注意:如果你近距离观察,镜子中的图像相比原始图像看起来有些模糊。这是因为被计算的纹理坐标通常不会精确地对应一个像素,所以显卡会取最近像素的平均,平均化的过程对应模糊化的操作(见教程2-12)。 代码 要加载technique,需要将. Fx文件加载到一个Effect变量中,还需要定义镜子: private void InitMirror() { mirrorVertices = new VertexPositionTexture[4]; int i = 0; Vector3 p0 = new Vector3(-3, 0, 1); Vector3 p1 = new Vector3(-3, 6, 0); Vector3 p2 = new Vector3(6, 0, 0); Vector3 p3 = p1 + p2 - p0; mirrorVertices[i++] = new VertexPositionTexture(p0, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p1, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p2, new Vector2(0,0)); mirrorVertices[i++] = new VertexPositionTexture(p3, new Vector2(0,0)); mirrorPlane = new Plane(p0, p1, p2); } 当相机位置发生改变时,你需要更新mirrorViewMatrix和clipPlane变量,因为它们取决于普通View矩阵: private void UpdateMirrorViewMatrix() { Vector3 mirrorCamPosition = MirrorVector3(mirrorPlane, fpsCam.Position); Vector3 mirrorTargetPosition = MirrorVector3(mirrorPlane,fpsCam.TargetPosition); Vector3 camUpPosition = fpsCam.Position + fpsCam.UpVector; Vector3 mirrorCamUpPosition = MirrorVector3(mirrorPlane, camUpPosition); Vector3 mirrorUpVector = mirrorCamUpPosition - mirrorCamPosition; mirrorViewMatrix = Matrix.CreateLookAt(mirrorCamPosition, mirrorTargetPosition, mirrorUpVector); } private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3) { float distV3ToPlane = mirrorPlane.DotCoordinate(originalV3); Vector3 mirroredV3 = originalV3 - 2 * distV3ToPlane * mirrorPlane.Normal; return mirroredV3; } private void UpdateClipPlane() { Matrix camMatrix = mirrorViewMatrix * fpsCam.ProjectionMatrix; Matrix invCamMatrix = Matrix.Invert(camMatrix); invCamMatrix = Matrix.Transpose(invCamMatrix); Vector4 mirrorPlaneCoeffs = new Vector4(mirrorPlane.Normal, mirrorPlane.D); Vector4 clipPlaneCoeffs = Vector4.Transform(-mirrorPlaneCoeffs, invCamMatrix); clipPlane = new Plane(clipPlaneCoeffs); } 在绘制过程中,你首先将从镜像相机中看到的场景绘制到一张纹理中,然后清除屏幕,绘制从普通相机中看到的场景。之后绘制镜子: protected override void Draw(GameTime gameTime) { //render scene as seen by mirror into render target device.SetRenderTarget(0, renderTarget); device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); device.ClipPlanes[0].Plane = clipPlane; device.ClipPlanes[0].IsEnabled = true; RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix); device.ClipPlanes[0].IsEnabled = false; //deactivate custom render target, and save its contents into a texture device.SetRenderTarget(0, null); mirrorTexture = renderTarget.GetTexture(); //render scene + mirror as seen by user to screen graphics.GraphicsDevice.Clear(Color.Tomato); RenderScene(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); RenderMirror(); base.Draw(gameTime); } 镜子使用mirror technique绘制为一个简单的矩形,它的颜色来自于mirrorTexture变量: private void RenderMirror() { mirrorEffect.Parameters["xWorld"].SetValue(Matrix.Identity); mirrorEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); mirrorEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); mirrorEffect.Parameters["xMirrorView"].SetValue(mirrorViewMatrix); mirrorEffect.Parameters["xMirrorTexture"].SetValue(mirrorTexture); mirrorEffect.Begin(); foreach (EffectPass pass in mirrorEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, mirrorVertices, 0, 2); pass.End(); } mirrorEffect.End(); } 对镜子的每个顶点,vertex shader计算2D屏幕位置和xMirrorTexture中对应的位置: MirVertexToPixel MirrorVS(float4 inPos: POSITION0) { MirVertexToPixel Output = (MirVertexToPixel)0; float4x4 preViewProjection = mul(xView, xProjection); float4x4 preWorldViewProjection = mul(xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); float4x4 preMirrorViewProjection = mul (xMirrorView, xProjection); float4x4 preMirrorWorldViewProjection = mul(xWorld, preMirrorViewProjection); Output.TexCoord = mul(inPos, preMirrorWorldViewProjection); return Output; } pixel shader使用将这个坐标除以其次坐标,将位置从 [–1,1] 渲染目标区间映射到[0,1]纹理坐标区间。使用结果纹理坐标,采样xMirrorTexture对应位置的颜色并返回: MirPixelToFrame MirrorPS(MirVertexToPixel PSIn) : COLOR0 { MirPixelToFrame Output = (MirPixelToFrame)0; float2 ProjectedTexCoords; ProjectedTexCoords[0] = PSIn.TexCoord.x/PSIn.TexCoord.w/2.0f +0.5f; ProjectedTexCoords[1] = -PSIn.TexCoord.y/PSIn.TexCoord.w/2.0f +0.5f; Output.Color = tex2D(textureSampler, ProjectedTexCoords); return Output; }

处理2D图像和纹理——创建一个3D爆炸效果,简单的粒子系统

clock 十二月 4, 2010 14:22 by author alex
问题 你想在3D世界中创建一个漂亮的3D爆炸效果。 解决方案 你可以通过混合大量的小火焰图像(叫做粒子)创建一个爆炸效果,,如图3-22所示。注意一个单独的粒子很暗,但是,如果你添加大量的粒子,就会获得一个漂亮的爆炸效果。 图3-22 单个爆炸粒子 在爆炸的开始阶段,所有粒子都位于爆炸的初始位置,因为所有图像的颜色都叠加在了一起,所以会形成一个明亮的火球,绘制粒子时你需要使用additive blending才能获得这个效果。 随着时间流逝,粒子会从初始位置离开。而且,在离开中心的过程中还要使每个粒子图像变得更小更暗(淡出)。 一个漂亮的3D爆炸需要用到50至100个粒子。因为这些图像只是显示在3D世界中的2D图像,需要用到billboard,所以这个教程是建立在教程3-11基础上的。 你将创建一个基于shader的实时粒子系统。每一帧中,计算每个粒子的存活时间(age),它的位置、颜色和大小是基于存活时间计算的。当然,应该在GPU中进行这些计算,让CPU可以完成更重要的工作。 工作原理 如前所述,本章会重用教程3-11的代码,所以需要理解球形billboarding的原理。对每个粒子,你仍需定义六个顶点,而且只需定义一次,将它们传递到显卡。对每个顶点,需要包含下列数据用于vertex shader: 爆炸的初始位置的billboard中心点3D位置(用于billboarding) (Vector3 = 三个浮点数) 纹理坐标(用于billboarding) (Vector2 = 两个floats) 粒子创建的时间(一个浮点数) 粒子的存活时间(一个浮点数) 粒子移动的方向(Vector3 = 三个浮点数) 一个随机数,用于给每个粒子施加不同的行为(一个浮点数) 从这些数据,GPU可以基于当前时间计算所需的所有东西。例如,它可以计算粒子已经存活了多少时间。当你将这个存活时间乘以粒子的方向时,就可以获取粒子离开爆炸初始位置的距离。 你需要一个顶点格式存储这个数据,使用一个Vector3存储位置;一个Vector4存储第二,第三,第四个数据;另一个Vector4存储最后两个数据。创建自定义顶点格式可参见教程5-14: public struct VertexExplosion { public Vector3 Position; public Vector4 TexCoord; public Vector4 AdditionalInfo; public VertexExplosion(Vector3 Position, Vector4 TexCoord, Vector4 AdditionalInfo) { this.Position = Position; this.TexCoord = TexCoord; this.AdditionalInfo = AdditionalInfo; } public static readonly VertexElement[] VertexElements = new VertexElement[] { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, 12, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, 28, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 4 + 4); } 这看起来与教程3-11中的很像,除了用一个Vector4代替Vector2作为第二个参数,让额外的两个浮点数可以传递到vertex shader。要创建随机方向,需要使用一个randomizer,所以在XNA类中添加以下变量: Random rand; 在Game类的Initialize方法中进行初始化: rand = new Random(); 现在可以创建顶点了。下面的方法基于教程3-11生成顶点: private void CreateExplosionVertices(float time) { int particles = 80; explosionVertices = new VertexExplosion[particles * 6]; int i = 0; for (int partnr = 0; partnr < particles; partnr++) { Vector3 startingPos = new Vector3(5,0,0); float r1 = (float)rand.NextDouble() - 0.5f; float r2 = (float)rand.NextDouble() - 0.5f; float r3 = (float)rand.NextDouble() - 0.5f; Vector3 moveDirection = new Vector3(r1, r2, r3); moveDirection.Normalize(); float r4 = (float)rand.NextDouble(); r4 = r4 / 4.0f * 3.0f + 0.25f; explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection,r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); } } 这个方法需要在初始化一个新爆炸时调用,它接受当前时间为参数。首先定义了爆炸中使用的粒子数量,创建了一个数组保存每个粒子的六个顶点。然后,创建这些顶点。对每个顶点,首先储存startingPos,即爆炸的中心点。 然后,你想让每个粒子都有不同的方向。这可以通过生成两个[0,1]区间的随机数实现,但需要减去0.5使它们落在[–0.5f, +0.5f]区间。基于这个随机值创建一个Vector3,归一化这个Vector3让随机方向具有相同的长度。 技巧:推荐使用归一化,因为向量(0.5f, 0.5f, 0.5f)比向量(0.5f, 0, 0)长。所以,当你增加第一个向量的位置时,如果使用第二个向量作为运动方向,粒子会运动得更快。结果是,爆炸会变得像个立方体而不是球形。 然后,获取另一个随机值,用来给每个粒子施加各自的效果,这是因为粒子的速度和大小都要由这个值进行调整。要求有一个介于0和1之间的随机数,然后缩放到[0.25f, 1.0f]区间(谁想要一个速度为的粒子?)。 最后,将每个粒子的六个顶点添加到顶点数组中。注意每六个顶点都包含相同的信息,除了纹理坐标,纹理坐标用来在vertex shader定义每个顶点偏离中心位置的偏移量。 HLSL 现在准备好了顶点,可以编写vertex和pixel shader了。 首先定义XNA-HLSL变量,纹理采样器,vertex shader和pixel shader的输出结构: //XNA interface float4x4 xView; float4x4 xProjection; float4x4 xWorld; float3 xCamPos; float3 xCamUp; float xTime; //Texture Samplers Texture xExplosionTexture; sampler textureSampler = sampler_state { texture = <xExplosionTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct ExpVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; float4 Color : COLOR0; }; struct ExpPixelToFrame { float4 Color : COLOR0; }; 因为爆炸中的每个粒子都要施加billboard,你需要相同的变量。这次,你还需要在XNA程序的xTime变量中存储当前时间,让vertex shader可以计算每个粒子的生存时间。 如教程3-11所述,vertex shader将2D屏幕位置和纹理坐标传递到pixel shader中。因为你想实现粒子的淡出效果,所以还要从vertex shader中传递颜色信息。和往常一样,pixel shader只计算每个像素的颜色。 Vertex Shader vertex shader对顶点施加billboard,所以使用以下代码,这个代码来自于教程3-11中的球形billboarding: //Technique: Explosion float3 BillboardVertex(float3 billboardCenter, float2 cornerID, float size) { float3 eyeVector = billboardCenter - xCamPos; float3 sideVector = cross(eyeVector,xCamUp); sideVector = normalize(sideVector); float3 upVector = cross(sideVector,eyeVector); upVector = normalize(upVector); float3 finalPosition = billboardCenter; finalPosition += (cornerID.x-0.5f)*sideVector*size; finalPosition += (0.5f-cornerID.y)*upVector*size; return finalPosition; } 简而言之,你将billboard的中心位置,纹理坐标(用来区分当前顶点)和billboard大小传递到这个方法,这个方法返回顶点的3D位置。更多的信息可参见教程3-11。 下面是vertex shader,它将使用刚才定义的方法: ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { ExpVertexToPixel Output = (ExpVertexToPixel)0; float3 startingPosition = mul(inPos, xWorld); float2 texCoords = inTexCoord.xy; float birthTime = inTexCoord.z; float maxAge = inTexCoord.w; float3 moveDirection = inExtra.xyz; float random = inExtra.w; } vertex shader接受存储在每个顶点中的一个Vector3和两个Vector4。首先,将数据中的内容存储在一些变量中。Billboard中心位置存储在Vector3中。你已经定义了第一个Vector4中的x和y分量包含纹理坐标,第三第四个分量包含粒子创建的时间和存活时间。第二个Vector4包含粒子移动的方向和一个额外的随机浮点数。 现在,你可以将当前时间减去birthTime获取粒子已经存在的时间,存储在xTime变量中。但是,当使用time span时,你想用0和1之间的相对值表示这个时间,0表示开始时刻,1表示结束时刻。所以要将age除以maxAge, maxAge表示此时粒子应该“死亡”。 float age = xTime - birthTime; float relAge = age/maxAge; 当粒子是新的时,xTime与birthTime相同,relAge 为0。当接近于结束,age几乎等于maxAge,relAge接近于1。 首先根据粒子的age 调整它的大小。你想让每个粒子开始时最大然后慢慢变小。但是,你不想让它完全消失,所以需要使用以下代码: float size = 1-relAge*relAge/2.0f; 看起来有点难以理解,如果用图表示可以帮助你理解这个代码,如图3-22左图所示。对横轴上的每个粒子的Age,你可以在在纵轴上找到对应的大小。例如,relAge = 0时大小为1,relAge = 1时大小为0.5f。 图3-23 Size-relAge函数; displacement-relAge函数 但是,当relAge为1时会继续减小。这意味着size会变为负值(如下面的代码所见,这会导致图像会在反方向扩展)。所以,你需要将这个值 处于0和1之间。 因为大小为1的粒子太小,只需简单地将它们乘以一个数字进行缩放,这里是5。要让每个粒子各不相同,你还要将大小乘以一个随机数: float sizer = saturate(1-relAge*relAge/2.0f); float size = 5.0f*random*sizer; 现在粒子的大小随着时间的流逝会变小,下一步是计算粒子中心的3D位置。你已经知道初始3D位置(爆炸的中心位置)和粒子的移动方向。你需要知道粒子已经沿着这个方向移动了多远,当然,这个距离对应粒子的生存时间。一个简单的方法是让粒子以一个不变的速度移动,但是实际情况中这个速度会减小。 看一下图3-23中的右图,水平轴表示粒子的生存时间,竖直轴表示粒子离开中心位置的距离。你看到距离一开始是线性增加的,但过了一会儿,这个距离增加速度减小,最后不变。这意味着开始时速度保持不变最后为0。 幸运的是,这个曲线只是简单的正弦函数的四分之一。对于一个任意给定的位于0和1之间的relAge,你可以使用这个函数找到对应的曲线上的距离: float totalDisplacement = sin(relAge*6.28f/4.0f); 一个完整的正弦曲线周期是从0到2*pi=6.28。因为你只想要四分之一周期,所以需要除以4。现在对于0和1之间的relAge,totalDisplacement拥有了一个对应图3-23右图所示的曲线的值。 你还要将这个值乘以一个因子使粒子离开中心一点儿,本例中这个因子为3比较合适。更大的值导致更大的爆炸。这个值还要乘以一个随机值,使每个粒子都有自己的速度: float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random; 一旦知道了粒子沿着运动方向运动的距离,就可以很简单地获取粒子的当前位置: float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; billboardCenter += age*float3(0,-1,0)/1000.0f; 最后一行代码给粒子施加一个重力。因为xTime变量包含当前的以毫秒为单位的时间,这行代码会每秒让粒子下降一个单位。 技巧:如果你想对一个移动的物体施加爆炸效果,例如一架飞机,你只需简单地添加一条代码让所有粒子移向物体移动的方向。你可以在顶点的一个额外的TEXCOORD2向量中传递这个方向。 现在有了billboard 中心的3D位置和大小,你就做好了将这些值传递到billboarding方法中的准备: float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection); 这个代码来自于教程3-11。首先计算billboarded位置。和往常一样,这个3D位置需要乘以一个 4 × 4矩阵转换为2D屏幕位置,但在这之前,float3需要被转换到float4。你将最终的2D位置存储在Output结构的Position变量中。 以上代码已经可以给出一个漂亮的结果,但是当粒子接近结束时再施加淡出效果会更棒。当粒子刚刚生成时,你想让它完全可见,当达到relAge = 1时,你想让它完全透明。 要实现这个效果,你可以使用线性减少,但是如果使用图3-23左图中的曲线效果会更好。这次,你想从1到0,所以无需除以2: float alpha = 1-relAge*relAge; 现在就可以定义粒子的调制颜色了: Output.Color = float4(0.5f,0.5f,0.5f,alpha); 在pixel shader中,你将每个像素的颜色乘以这个颜色。这会将像素的alpha值调整为这里计算的值,这样粒子就会慢慢变得透明。颜色的RGB分量也通过因子2柔化,不然你会很容易看到独立的粒子。 别忘了将纹理坐标传递到pixel shader,这样才能知道billboard的顶点对应纹理的哪个顶点: Output.TexCoord = texCoords; return Output; Pixel Shader 幸运的是,pixel shader相对于vertex shader来说很简单: ExpPixelToFrame ExplosionPS(ExpVertexToPixel PSIn) : COLOR0 { ExpPixelToFrame Output = (ExpPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord)*PSIn.Color; return Output; } 对每个像素,你从纹理中获取对应的颜色,将这个颜色乘以在vertex shader计算的调制颜色。这个调制颜色会减少亮度,对应粒子的生存时间设置alpha值。 下面是technique定义: technique Explosion { pass Pass0 { VertexShader = compile vs_1_1 ExplosionVS(); PixelShader = compile ps_1_1 ExplosionPS(); } } 在XNA中设置Technique参数 别忘了在XNA代码中设置所有XNA-to-HLSL变量: expEffect.CurrentTechnique = expEffect.Techniques["Explosion"]; expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); expEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix); expEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix); expEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position); expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); expEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector); expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds); Additive Blending(附加混合) 因为这个technique依赖于混合,你需要在绘制前设置绘制状态,alpha混合的更多信息可参加教程2-12和3-3。 在本例中,你想使用additive blending让所有的颜色都叠加在一起。这可以通过设置渲染状态实现: device.RenderState.AlphaBlendEnable = true; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.One; 第一行代码开启alpha混合。对每个像素,它的颜色决定于以下规则: finalColor=sourceBlend*sourceColor+destBlend*destColor 根据前面设置的渲染状态,这个规则会变成以下公式: finalColor=sourceAlpha*sourceColor+1*destColor 显卡会绘制每个像素多次,因为你将绘制大量的爆炸图像(需要关闭写入depth buffer)。所以当显卡已经绘制了80个粒子中的79个,并开始绘制最后一个时,会对这个粒子的每个像素进行以下操作: 1.找到存储在frame buffer中的像素的当前颜色,将三个颜色通道乘以1(对应前面规则中的1*destColor)。 2.获取在pixel shader中计算的这个粒子的新颜色,将这个颜色的三个通道乘以alpha通道,alpha通道取决于粒子的当前生存时间(对应上述规则中的sourceAlpha*sourceColor)。 3. 将两个颜色相加,将结果保存到frame buffer中。 在粒子的开始时刻(relAge=0),sourceAlpha等于1,所以additive blending会产生一个大爆炸。在粒子的结束时刻(relAge=1),newAlpha为0,粒子不产生效果。 注意:这个混合规则的第二个例子可参加教程2-12。 关闭写入到Depth Buffer 你还要考虑到最后一个方面。当离开相机最经的粒子首先被绘制时,所有在它之后的粒子将不会被绘制(这会导致不发生混合)!所以绘制粒子时,你需要关闭z-buffer测试,并在将粒子作为场景中的最后一个元素被绘制。但这并不是一个好方法,因为当爆炸离开相机很远时且相机和爆炸之间有一个建筑物时,爆炸仍会被绘制,就好像之间没有这个建筑物似的! 更好的方法是先绘制场景。然后关闭z-buffer写入将粒子作为最后一个元素进行绘制。通过这个方法,你可以解决这个问题: 每个粒子的每个像素都会与z-buffer中的当前内容(包含深度信息)作比较。如果在相机和爆炸之间已经有一个物体被绘制了,那么爆炸的粒子就通不过z-buffer测试,不会被绘制。 因为粒子不会改变z-buffer,就算第二个粒子在第一个粒子之后也会被绘制(当然,是在相机和爆炸间没有另一个物体的情况下)。 下面是代码: device.RenderState.DepthBufferWriteEnable = false; 然后,绘制爆炸使用的三角形: expEffect.Begin(); foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); pass.End(); } expEffect.End(); 在绘制完爆炸后,你需要再次开启z-buffer写入: device.RenderState.DepthBufferWriteEnable = true; 当你想开始一个新的爆炸时,调用CreateExplosionVertices方法!本例中,当用户按下空格键时会调用这个方法: if ((keyState.IsKeyDown(Keys.Space))) CreateExplosionVertices((float) gameTime.TotalGameTime.TotalMilliseconds); 代码 下面是生成爆炸所需顶点的方法: private void CreateExplosionVertices(float time) { int particles = 80; explosionVertices = new VertexExplosion[particles * 6]; int i = 0; for (int partnr = 0; partnr < particles; partnr++) { Vector3 startingPos = new Vector3(5,0,0); float r1 = (float)rand.NextDouble() - 0.5f; float r2 = (float)rand.NextDouble() - 0.5f; float r3 = (float)rand.NextDouble() - 0.5f; Vector3 moveDirection = new Vector3(r1, r2, r3); moveDirection.Normalize(); float r4 = (float)rand.NextDouble(); r4 = r4 / 4.0f * 3.0f + 0.25f; explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4)); explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4)); } } 下面是完整的Draw方法: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); cCross.Draw(quatCam.ViewMatrix, quatCam.ProjectionMatrix); if (explosionVertices != null) { //draw billboards expEffect.CurrentTechnique = expEffect.Techniques["Explosion"]; expEffect.Parameters["xWorld"].SetValue(Matrix.Identity); expEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix); expEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix); expEffect.Parameters["xCamPos"].SetValue(quatCam.Position); expEffect.Parameters["xExplosionTexture"].SetValue(myTexture); expEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector); expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds); device.RenderState.AlphaBlendEnable = true; device.RenderState.SourceBlend = Blend.SourceAlpha; device.RenderState.DestinationBlend = Blend.One; device.RenderState.DepthBufferWriteEnable = false; expEffect.Begin(); foreach (EffectPass pass in expEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements); device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3); pass.End(); } expEffect.End(); device.RenderState.DepthBufferWriteEnable = true; } base.Draw(gameTime); } 下面是vertex shader代码: ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) { ExpVertexToPixel Output = (ExpVertexToPixel)0; float3 startingPosition = mul(inPos, xWorld); float2 texCoords = inTexCoord.xy; float birthTime = inTexCoord.z; float maxAge = inTexCoord.w; float3 moveDirection = inExtra.xyz; float random = inExtra.w; float age = xTime - birthTime; float relAge = age/maxAge; float sizer = saturate(1-relAge*relAge/2.0f); float size = 5.0f*random*sizer; float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random; float3 billboardCenter = startingPosition + totalDisplacement*moveDirection; billboardCenter += age*float3(0,-1,0)/1000.0f; float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size); float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection); float alpha = 1-relAge*relAge; Output.Color = float4(0.5f,0.5f,0.5f,alpha); Output.TexCoord = texCoords; return Output; } vertex shader使用前面定义的BillboardVertex方法,在教程的最后你还可以看到简单的pixel shader和technique定义。 译者注:在XNA官方网站上http://creators.xna.com/en-US/sample/particle3d也有一个粒子系统的例子,不过它的实现方法是使用点精灵(point sprite),这个方法没有使用billboard那样具有弹性,但是它的优点是只使用一个顶点就可以绘制一个粒子,而在billboard需要六个,这可以减少发送到显卡的数据量。

处理2D图像和纹理——Billboarding:在3D世界绘制2D图像使它们总是面向相机

clock 十二月 4, 2010 14:17 by author alex
问题 要使画面给人留下深刻印象,一个3D世界需要包含许多物体,特别是处理室外场景时。例如,如果没有绘制成百上千棵树,树林看起来就会显得不真实。但是,以3D模型的形式绘制几百颗树是不可能的,因为这样做会极大地拖慢应用程序。 你也可以使用少量的billboarding(译者注:billboard可以翻译为公告板,广告牌),例如,绘制激光束或子弹。这通常和一个粒子引擎组合在一起(见教程3-12)。 解决方案 你可以通过使用一张2D图像代替3D物体解决这个问题。但是当相机在2D图像旁边时,观察者很容易看出这是一张2D图像,如图3-16的左图所示,左图中有5张2D图像放置在3D世界中。 图3-16 3D场景中的2D图像。没有bilboarded (左图),有billboarded (右图)。 要解决这个问题,对每个图像你想在3D空间中定义两个三角形显示这个图像,而且你想旋转这些三角形使图像能够朝向相机。如图3-16中的右图所示,有与左图相同的5张2D图像,但经过旋转朝向相机。如果这些图像包含树木,它们的边界会使用透明色,可以实现一个漂亮的效果。 XNA Framework包含计算每个图像两个三角形的六个顶点的旋转位置的功能,这个功能由Matrix. CreateBillboard方法提供,但是在vertex shader中进行这些计算会获得极大的性能提升,这会在本教程的第二部分解释。 工作原理 作为树林或粒子引擎的共同部分,你想只定义2D图像的中心的3D位置和它的大小。所以,你想使用一个集合包含每个2D图像的3D位置和大小: List<Vector4> billboardList = new List<Vector4>(); Texture2D myTexture; VertexPositionTexture[] billboardVertices; 可以看到billboards存储为一个Vector4:三个浮点数存储中心的3D位置,另一个浮点数保存billboard的大小。 对每个billboarded 2D图像,你需要计算图像的两个三角形的六个顶点。这些顶点存储在最后一行代码定义的billboardVertices变量中。myTexture变量保存用来给显卡采样颜色的纹理。 技巧:如教程3-4中所述,绘制多个纹理的最快方法是将它们存储在一张大纹理中,这样显卡就无需切换纹理了。 在LoadContent方法中添加以下代码将纹理加载到myTexture变量中: myTexture = content.Load<Texture2D>("billboardtexture"); 然后添加一个简单的方法让你可以容易地将billboard添加到场景中: private void AddBillboards() { billboardList.Add(new Vector4(-20, -10, 0, 10)); billboardList.Add(new Vector4(0, 0, 0, 5)); billboardList.Add(new Vector4(20, 10, 0, 10)); } 现在这个方法只添加了三个billboard。第四个分量表示你想让第二个billboard大小只有另两个的四分之一,因为边长为另两个的二分之一。别忘了在Initialize方法中调用这个方法: AddBillboards(); 然后,你需要计算六个顶点的位置。Billboarding有两种主要方式:球面billboarding和圆柱面billboarding。球面billboarding主要用于粒子引擎,圆柱面billboarding主要用于绘制树木,人等。 为球面Billboarding计算六个顶点位置 技巧:如果你想使用HLSL实现billboard,可以略过此节直接看本教程的“性能考虑:第二部分”,因为在GPU上进行billboarding计算可以给程序带来极大的提升。“性能考虑:第一部分”也值得一读。 现在你已经定义了billboard的中心位置,可以计算中心点周围的六个顶点了: private void CreateBBVertices() { billboardVertices = new VertexPositionTexture[billboardList.Count * 6]; int i = 0; foreach (Vector4 currentV4 in billboardList) { Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z); float scaling = currentV4.W; //add rotated vertices to the array } } 上述代码首先创建了一个数组存储每个billboard的两个三角形的六个顶点。然后,对存储在billboardList 中的每个billboard,获取它的中心位置和大小。for循环中的其余部分是计算六个顶点位置的实际代码。 对每张2D图像,你需要定义两个三角形用以在3D空间定义一个矩形。这意味着你需要定义六个顶点,但其中四个是独立的。 因为需要显示一张纹理,所以使用VertexPosit ionTexture。因为billboardList保存每个矩形的中心位置,你想让所有的顶点到中心的距离相同。在本例中,需要到/从中心的X和Y分量中增加/减少0.5f的偏移量获取顶点的位置。 例如,下面的三个位置保存离开一个三角形中心位置的偏移量,DL指左下,UR指右上: Vector3 posDL = new Vector3(-0.5f, -0.5f, 0); Vector3 posUR = new Vector3(0.5f, 0.5f, 0); Vector3 posUL = new Vector3(-0.5f, 0.5f, 0); 如果你将中心位置添加到上述位置中,就获得了一个三角形的顶点位置。但是,无论相机的位置如何,这些位置将保持不变。所以当你移动相机时,就会看到这个三角形的侧面,如图3-16的左图所示。而你想让三角形始终朝向相机,因此需要根据相机的位置对这些偏移量进行某种形式的旋转。这需要数学知识,幸运的是,XNA可以立即生成一个矩阵用来进行这种变换。在for循环的顶部添加以下代码: Matrix bbMatrix = Matrix.CreateBillboard(center, quatCam.Position, quatCam.UpVector, quatCam.Forward); 要能创建这个矩阵用来旋转三角形使图像始终朝向相机,XNA需要知道矩形和相机的位置,相机的Forward向量。因为你想让球形billboarding旋转图像使它根据相机朝向上方,你还需要指定Up向量。 注意:当你颠倒相机时,矩形也会随之颠倒。要展示这种情况,本例中使用了一个向上箭头作为纹理。而且,为了让你不受限制地旋转相机,使用了教程2-4中的四元数相机而不是教程2-3中的quake相机。 有了这个矩阵,就可以获取旋转偏移量: Vector3 posDL = new Vector3(-0.5f, -0.5f, 0); Vector3 billboardedPosDL = Vector3.Transform(posDL*scaling, bbMatrix); billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1)); 第一行代码定义了一个静态偏移量,并没有考虑相机的位置。你还需指定矩形的大小,将偏移量乘以scaling值,这样矩形会在 AddBillboards 方法中成为你指定的大小。现在知道了顶点相对于中心的偏移量,使用矩阵进行变换。最后,在顶点数组中存储这个变换过的位置和纹理坐标。 如果对三角形的三个顶点都进行这样的操作,那么三角形就会始终朝向相机。 注意:你只对偏移量进行变换:相对于中心的顶点平移会通过矩阵变换自动实现!这也是创建bbMatrix时需要指定矩形中心位置的原因之一。 你需要对每个偏移量都进行这个变换。因为两个三角形共享两个两个点,因此只需进行四次变换。下面是CreateBBVertices方法: private void CreateBBVertices() { billboardVertices = new VertexPositionTexture[billboardList.Count * 6]; int i = 0; foreach (Vector4 currentV4 in billboardList) { Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z); float scaling = currentV4.W; Matrix bbMatrix = Matrix.CreateBillboard(center, quatCam.Position, quatCam.UpVector, quatCam.Forward); //first triangle Vector3 posDL = new Vector3(-0.5f, -0.5f, 0); Vector3 billboardedPosDL = Vector3.Transform(posDL * scaling, bbMatrix); billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1)); Vector3 posUR = new Vector3(0.5f, 0.5f, 0); Vector3 billboardedPosUR = Vector3.Transform(posUR * scaling, bbMatrix); billboardVertices[i++] = new VertexPositionTexture(billboardedPosUR, new Vector2(0, 0)); Vector3 posUL = new Vector3(-0.5f, 0.5f, 0); Vector3 billboardedPosUL = Vector3.Transform(posUL * scaling, bbMatrix); billboardVertices[i++] = new VertexPositionTexture(billboardedPosUL, new Vector2(1, 0)); //second triangle: 2 of 3 corner points already calculated! billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1)); Vector3 posDR = new Vector3(0.5f, -0.5f, 0); Vector3 billboardedPosDR = Vector3.Transform(posDR * scaling, bbMatrix); billboardVertices[i++] = new VertexPositionTexture(billboardedPosDR, new Vector2(0, 1)); billboardVertices[i++] = new VertexPositionTexture(billboardedPosUR, new Vector2(0, 0)); } } 对billboardList中的每个项目,这个方法都会计算四个点,让矩形朝向相机。 确保在相机的位置或旋转发生变化时调用这个方法,因为所有billboard的旋转都会相应地进行调整!为了安全起见,在Update方法的最后调用这个方法。 从顶点数组绘制 有了包含3D位置和纹理坐标的所有顶点,你就可以在Draw方法中绘制它们了,解释请见教程5-1: //draw billboards basicEffect.World = Matrix.Identity; basicEffect.View = quatMousCam.ViewMatrix; basicEffect.Projection = quatMousCam.ProjectionMatrix; basicEffect.TextureEnabled = true; basicEffect.Texture = myTexture; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2); pass.End(); } basicEffect.End(); 你想从billboardVertices数组进行绘制,数组中的顶点表示三角形的集合。因为每个2D图像都是一个矩形,需要绘制两个三角形,所有你需要绘制总共billboard List . Count*2 个三角形。因为使用的是TriangleList,这个数量等于billboardVertices. Length/3。 性能考虑:第一部分 当使用billboard时,很容易碰到需要绘制几千个billboard的情况。例如你可以像以下代码一样定义几千个billboards: private void AddBillboards() { int CPUpower = 10; for (int x = -CPUpower; x < CPUpower; x++) for (int y = -CPUpower; y <CPUpower; y++) for (int z = -CPUpower; z < CPUpower; z++) billboardList.Add(new Vector4(x, y, z, 0.5f)); } 上述代码会使用边长为0.5f的billboard填充从(-10, -10, -10)到(9, 9, 9)范围内的立方体,billboard 间的距离为1个单位,总共有8000个billboards!如果在你的PC上运行得不流畅,可以试着减少CPU power值,这样绘制的billboard更少。 你的PC之所以可以绘制这么多的billboard只是因为你使用了一个很大的顶点数组存储了所有biillboard。通过这种方式,显卡可以一次性在单一的过程中处理16000个三角形。这个代码与前面的代码相比还有一个小变化。 在前面的代码中,对每个billboard都会创建一个新的顶点数组并填充了六个顶点,与前面的方式一样。然后,对每个billboard,显卡从六个顶点绘制两个三角形,整个过程周而复始。对CPU来说几乎同样的工作量,显卡需要被调用8000次绘制两个三角形,在这个任务中显卡被打搅了7999次,因此显卡很“生气”。 使用这个方法,我可以将CPUpower设为3减少帧率的下降,这种情况下只绘制了216个! 所以底线是让显卡在一次性在单一过程中做自己的工作,这也是显卡喜欢的工作方式。请总是从尽可能少的顶点缓冲/纹理中绘制场景。 为圆柱形Billboarding计算六个顶点 在某些情况中,你并不想让矩形朝向相机。例如,如果使用一张树的2D图像创建一片树林,你想让每张图像绕着树木的主干旋转。使用球形billboarding会导致绕着三个轴旋转,使它们完全朝向相机。 想象一下这种情况,你有一些树木的图像,相机在它们上面。使用球形billboarding,结果如图3-17的左上所示。在图3-17的左下,你可以看到为了让矩形朝向相机,树木以一种不自然的方式进行了旋转,当相机移动时会产生一些奇怪的效果。 你想要的效果是让矩形只绕着树木的Up向量旋转,如图3-17的右下所示。在3D视角中,会产生如图3-17中右上图所示的效果。 图3-17 球形billboarding (左),圆柱形billboarding (右) 注意:这种billboarding之所以称之为圆柱形的,是因为如果你将相机放置在如此多的billboard图像之内,它们会成为一个圆柱体隧道。导致这个现象的原因是它们只被允许沿着一个方向旋转。图3-18显示了这样一个隧道。 计算每个billboard顶点的代码与前面的几乎一样。你只需使用另一个billboarding矩阵,这个矩阵可以通过使用Matrix.CreateConstrainedBillboard方法获取: Matrix bbMatrix = Matrix.CreateConstrainedBillboard(center,quatCam.Position, new Vector3(0, 1, 0), quatCam.Forward, null); 作为一个有约束的旋转,现在你可以指定billboard 绕着那条边旋转。对一颗树来说,这条边是(0,1,0) Up向量。为使当相机非常靠近物体时旋转更加精确,你也可以指定相机的Up向量。 图3-18 大量的圆柱形billboarding 性能考虑:第二部分 本教程的第一部分展示了如何使用XNA代码创建一个billboarding引擎。但是,这个方法会给CPU带来极大地压力,而对显卡利用效率不高。 想象一下在3D场景中绘制1000个billboard图像。只要相机改变位置,就需要重新计算图像四个顶点的位置使它们可以重新朝向相机。若更新频率为60次/秒,需要每秒计算60乘以4000个3D位置,这都会在CPU中进行。而且,每帧都需要创建一个6000个顶点的数组保存所需的三角形用来显示每个billboard。 CPU是通用目的的处理器,并没有对这种计算进行优化。这样的任务会拖慢CPU,限制了CPU做其他任务,例如处理游戏逻辑。 而且,每一帧这个更新过的顶点缓冲都要被发送到显卡中!这会让显卡将这个数据放置在系统内存中,在PCI-express (或 AGP)总线上进行大量的搬运工作。 如果你一次性地将每个billboard的中心位置和大小存储在显存中,让GPU(显卡的计算单元)代替CPU进行所有的billboarding计算,性能会有很大的提升。记住显卡对顶点的操作是经过优化的,这些运算在GPU中要比在CPU中要快得多。有以下几个优点: 在PCI-express (或AGP) 总线中只需进行一次传输 计算时间大大缩短(因为GPU对这种计算进行过优化) CPU不进行billboarding计算,让CPU可以做其他更重要的事情 针对HLSL Billboarding所做的代码准备 你仍可以使用一个集合存储billboard,所以AddBillboards方法无需改动。但是CreateBBVertices方法进行了简化,因为所有的billboarding计算在GPU的vertex shader中进行。 一个billboard仍使用两个三角形绘制,因此每对个billboard仍需传递六个顶点至GPU。 那么,你想让vertex shader做什么呢?你想让vertex shader计算六个顶点的3D旋转坐标使billboard可以朝向相机。要做到这步,vertex shader需要知道每个顶点的如下信息: 顶点所属的billboard的中心的3D位置 当前顶点位于billboard的哪个位置,这样vertex shader才可以计算对应的偏移量 纹理坐标,它们被传递到pixel shader用来从纹理中采样正确的位置 注意:显然在旋转billboard之前vertex shader还需要知道相机在3D空间中的位置。相机位置对每个顶点来说都是一样的,因此应该被设置为一个XNA-to-HLSL变量,而不是在每个顶点中存储这个位置。 这意味着属于一个billboard的六个顶点所携带的部分信息是相同的:billboard中心的3D位置。因为定义顶点的信息是四个顶点之一,你可以,例如,传递一个介于0和3之间的数字,0表示左上角顶点,3表示右下角顶点。 但是,你已经将这个信息通过纹理坐标的方式传递到vertex shader中了。事实上,(0,0)纹理坐标表示当前顶点为左上角顶点,(1,0)纹理坐标表示右上角顶点。 总而言之:对你想绘制的每个billboard,你需要绘制两个三角形,所以需要传递六个顶点到vertex shader中。这六个顶点携带了同样的位置信息:billboard的中心点位置。每个顶点都携带各自的纹理坐标,用来对两个三角形正确地施加纹理,但在这种情况中纹理坐标还被用vertex shader来确定当前顶点是哪个位置的顶点,所以vertex shader可以计算当前顶点离开 billboard中心点的偏移量。 所以在XNA代码中,需要调整CreateBBVertices方法使它生成这些顶点并将这些顶点存储在一个数组中: private void CreateBBVertices() { billboardVertices = new VertexPositionTexture[billboardList.Count * 6]; int i = 0; foreach (Vector4 currentV4 in billboardList) { Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 0)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1)); billboardVertices[i++] = new VertexPositionTexture(center, new Vector2(0, 0)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1)); billboardVertices[i++] = new VertexPositionTexture(center, new Vector2(0, 1)); } } 对集合中的每个billboard,在数组中添加六个顶点。每个顶点包含billboard中心点的位置信息和纹理坐标。 你只需在程序的开头调用这个方法一次,因为相机位置发生变化时数组的内容并不需要更新!所以在Initialize方法中在AddBillboards方法之后调用这个方法: AddBillboards(); CreateBBVertices(); 现在顶点已经做好了传递到GPU的准备,可以开始vertex shader的编写了。让我们从圆柱形billboarding开始,因为这个比球形billboarding容易点。 注意:将这个数据存储在显存中可以让你获益,因为你无需更新它的内容。这可以通过从billboardVertices数组创建一个VertexBuffer实现,可见教程5-4学习如何创建一个 VertexBuffer。 用于圆柱形Billboarding的Vertex Shader 首先定义变量,纹理和vertex/pixel shader输出结构: //XNA interface float4x4 xView; float4x4 xProjection; float4x4 xWorld; float3 xCamPos; float3 xAllowedRotDir; //Texture Samplers Texture xBillboardTexture; sampler textureSampler = sampler_state { texture = <xBillboardTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = CLAMP; AddressV = CLAMP; }; struct BBVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct BBPixelToFrame { float4 Color : COLOR0; }; 和往常一样要将3D世界绘制到2D屏幕中,需要World,View和Projection矩阵。要进行billboarding计算,你还需要知道相机的当前位置。因为你编写的是圆柱形billboarding,你还需要定义一个图像绕着旋转的轴。 你只需要一个纹理,将图像绘制在两个三角形中。对每个顶点,vertex shader会计算对应的2D屏幕坐标和纹理坐标。对每个像素,pixel shader将输出颜色。 在billboarding的情况中,vertex shader用来计算偏离中心位置的偏移量。让我们从编写添加偏移量的代码开始,billboarding是建立在这个代码的基础上的: // Technique: CylBillboard BBVertexToPixel CylBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { BBVertexToPixel Output = (BBVertexToPixel)0; float3 center = mul(inPos, xWorld); float3 upVector = float3(0,1,0); float3 sideVector = float3(1,0,0); float3 finalPosition = center; finalPosition += (inTexCoord.x-0.5f)*sideVector; finalPosition += (0.5f-inTexCoord.y)*upVector; float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection); Output.TexCoord = inTexCoord; return Output; } 对每个顶点,vertex shader接受定义在XNA项目中的位置和纹理坐标。注意这个位置是 billboard 中心点的3D位置。与World矩阵的乘积让你可以定义一个全局World矩阵旋转/缩放/平移所有billboards。最后的中心位置存储在一个叫做center的变量中,对一个billboard中的六个顶点都是相同的。 然后,定义一个静态的Side向量和Up向量。这些向量用来对顶点相对于billboard 中心点进行偏移。在图3-19中看一下如何偏移六个顶点,图中显示了一个billboard的两个三角形。注意看外部四个纹理坐标和三角形顶角上的六个顶点索引。 图3-19 六个顶点相对于中心位置的偏移量 接下来的代码计算指定顶点的3D位置。首先从中心位置开始然后,你想知道当前向量是否需要偏移到(-1,0,0)向左方向或(1,0,0)向右方向。你可以通过观察图3-19中的X纹理坐标进行这个判断:纹理坐标为(0,0)的顶点位于左上方,所以它需要移动到左边。纹理坐标为(1,0)的顶点在右上方,它需要被移动到右方。 以上的操作只需使用一行代码:首先将X纹理坐标减0.5,如果顶点在左侧结果是–0.5f,如果在右侧则为+0.5f。将结果乘以(+1,0,0)向量,左边的顶点结果为(–0.5f,0,0),右边的顶点结果为(+0.5f,0,0)! 同样的方法也用在Y纹理坐标上:纹理坐标(0,0)代表左上角顶点,(0,1)代表左下角顶点。所以如果Y纹理坐标为0,就是顶部顶点;如果为1则为底部顶点。 将正确的Side和Up偏移作用在中心位置上,你就获得了指定顶点的3D位置。 现在你要做的就是像往常一样使用ViewProjection矩阵将这个3D位置转换到2D屏幕空间中。在使用一个4 × 4矩阵将这个float3进行转换前,还需要使它变为一个float4,只需将1作为第四个坐标就可以了。 当使用pixel shader时,所有billboard都是漂亮的矩形,但是它们是互相平行的。这是因为你还没有使用代码中的相机位置计算偏移量,而这个偏移量用来旋转三角形使它们朝向相机。 显然在vertex shader中使用静态的Up和Side向量是无法实现上述功能的。圆柱形 billboarding容易些,因为你已经定义了billboard的Up向量:它是允许的旋转方向,通过XNA程序中的xAllowedRotDir变量被传递到shader中。 图3-20中的左图显示了两个billboard的例子,例如两棵树。因为你在XNA程序中指定了旋转方向作为树的Up向量,所以在vertex shader中已经知道了Up向量。 图3-20 两个圆柱形bilboard Billboard的Side向量预先不知道,但是你可以获取这个向量。你需要知道Eye向量,这个向量从眼睛指向billboard的中心,如图3-20中的虚线所示。你知道Side向量垂直于Eye向量和Up向量,如图3-20右图所示,显示的情况与左图相同,只是观察角度不同。 你可以通过叉乘两个向量获取同时垂直于这两个向量的向量(见教程4-18),所以这就是获取Side向量的方法。 现在的vertex shader如下所示: float3 center = mul(inPos, xWorld); float3 eyeVector = center - xCamPos; float3 upVector = xAllowedRotDir; upVector = normalize(upVector); float3 sideVector = cross(eyeVector, upVector); sideVector = normalize(sideVector); float3 finalPosition = center; finalPosition += (inTexCoord.x-0.5f)*sideVector; finalPosition += (0.5f-inTexCoord.y)*upVector; 你可以通过B-A获取任何从点A指向点B的向量,这个方法可以获取Eye向量。前面已经说过,billboard的Up向量是允许旋转的方向,是在XNA代码中指定的。因为Side向量垂直于Eye向量和Up向量,你可以通过叉乘这两个向量获取Side向量。 你需要确保Side和Up向量是单位长度(否则你无法控制billboard的大小),因此需要归一化这两个向量。 知道了billboard的Up和Side向量后,你可以重用前面变换3D位置的代码。现在vertex shader使用相机的当前位置计算顶点的最终位置,只要相机发生移动顶点就会改变它们的位置。 完成Billboarding:Pixel Shader和定义Technique 现在vertex shader可以计算顶点的3D坐标了,你要做的就是在pixel shader中将纹理施加在三角形上: BBPixelToFrame BillboardPS(BBVertexToPixel PSIn) : COLOR0 { BBPixelToFrame Output = (BBPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord); return Output; } 当然还要定义technique: technique CylBillboard { pass Pass0 { VertexShader = compile vs_1_1 CylBillboardVS(); PixelShader = compile ps_1_1 BillboardPS(); } } 现在你要做的就是将这个HLSL文件(即bbEffect. fx)导入到项目中,在LoadContent方法中加载到一个变量中: bbEffect = content.Load<Effect>("bbEffect"); 在Draw中,设置所需的XNA-to-HLSL变量并绘制billboards! bbEffect.CurrentTechnique = bbEffect.Techniques["CylBillboard"]; bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity); bbEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix); bbEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix); bbEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position); bbEffect.Parameters["xAllowedRotDir"].SetValue(new Vector3(0,1,0)); bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture); bbEffect.Begin(); foreach (EffectPass pass in bbEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2); pass.End(); } bbEffect.End(); 这里将(0,1,0) Up方向作为billboards旋转方向。 如果你的PC有一块独立显卡的话,你可以绘制比XNA-only版本多得多的billboard。 用于球形Billboarding的Vertex Shader 球形billboarding和圆柱形billboarding的区别在于球形billboarding中每个billboard都是完全朝向相机的。对vertex shader代码来说,Up和Side向量都要垂直于Eye向量(在圆柱形billboarding中,只有Side向量垂直于Eye向量)。 球形billboarding的挑战是你还需要获取Up向量,因为这个向量预先是不知道的。要解决这个问题,你首先需要知道相机的Up向量。 因为你想让billboard的整个面都朝向相机,所以需要将billboard的Side向量同时垂直于Eye向量和相机的Up向量。要展示这个情况,我在图3-21的左图中将Eye和CamUp向量所在平面涂成了灰色,这样你就可以看出Side向量是垂直于这个平面的。因为它们是垂直的,你已经知道如何获取Side向量了:叉乘Eye向量和CamUp向量: float3 sideVector = cross(eyeVector,xCamUp); sideVector = normalize(sideVector); 图3-21 为球形bilboarding找到Side和Up向量 知道了Side向量,你还可以获取 billboard 的Up向量:它垂直于Side向量和Eye向量。我在图3-21的右图中将Side和Eye向量所在平面涂成了灰色,让你可以将它形象化。这意味着billboard的Up向量就是Eye和Side向量的叉乘: float3 upVector = cross(sideVector,eyeVector); upVector = normalize(upVector); 这就是球形billboarding相对于圆柱形billboarding需要改变的地方!别忘了定义xCamUp variable . . . float3 xCamUp; . . .和一个新technique: technique SpheBillboard { pass Pass0 { VertexShader = compile vs_1_1 SpheBillboardVS(); PixelShader = compile ps_1_1 BillboardPS(); } } pixel shader是相同的。 确保在XNA项目中调用正确的technique并设置xCamUp参数: bbEffect.CurrentTechnique = bbEffect.Techniques["SpheBillboard"]; bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity); bbEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix); bbEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix); bbEffect.Parameters["xCamPos"].SetValue(quatCam.Position); bbEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector); bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture); 代码 注意你可以看到五种不同类型的billboarding的代码: XNA—only代码:球形billboarding XNA—only代码:圆柱形billboarding XNA + HLSL代码:球形billboarding XNA + HLSL代码:圆柱形billboarding XNA + HLSL代码:可变大小的球形billboarding 下面的代码是XNA+HLSL球形billboarding。下面的方法定义在哪放置billboards: private void AddBillboards() { int CPUpower = 10; for (int x = -CPUpower; x < CPUpower; x++) for (int y = -CPUpower; y <CPUpower; y++) for (int z = -CPUpower; z < CPUpower; z++) billboardList.Add(new Vector4(x, y, z, 0.5f)); } 下面的代码将集合转换为数组: private void CreateBBVertices() { billboardVertices = new VertexPositionTexture[billboardList.Count * 6]; int i = 0; foreach (Vector4 currentV4 in billboardList) { Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 0)); billboardVertices[i++]= new VertexPositionTexture(center, new Vector2(1, 1)); billboardVertices[i++]= new VertexPositionTexture(center, new Vector2(0, 1)); billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0)); } } 下面的代码调用正确的technique,设置它的参数,并从数组进行绘制: bbEffect.CurrentTechnique = bbEffect.Techniques["SpheBillboard"]; bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity); bbEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix); bbEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix); bbEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position); bbEffect.Parameters["xAllowedRotDir"].SetValue(new Vector3(0,1,0)); bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture); bbEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector); bbEffect.Begin(); foreach (EffectPass pass in bbEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2); pass.End(); } bbEffect.End(); 在HLSL部分,首先是vertex shader: BBVertexToPixel SpheBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { BBVertexToPixel Output = (BBVertexToPixel)0; float3 center = mul(inPos, xWorld); float3 eyeVector = center - xCamPos; float3 sideVector = cross(eyeVector,xCamUp); sideVector = normalize(sideVector); float3 upVector = cross(sideVector,eyeVector); upVector = normalize(upVector); float3 finalPosition = center; finalPosition += (inTexCoord.x-0.5f)*sideVector*0.5f; finalPosition += (0.5f-inTexCoord.y)*upVector*0.5f; float4 finalPosition4 = float4(finalPosition, 1); float4x4 preViewProjection = mul (xView, xProjection); Output.Position = mul(finalPosition4, preViewProjection); Output.TexCoord = inTexCoord; return Output; } 然后是简单的pixel shader: BBPixelToFrame BillboardPS(BBVertexToPixel PSIn) : COLOR0 { BBPixelToFrame Output = (BBPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord); return Output; } 传递额外的信息 如果你想设置vertex shader生成的billboard的大小或将额外信息从XNA程序传递到 vertex shader中,你需要将这个信息添加到每个顶点中。你可以通过,例如,VertexPositionNormalTexture和在Normal向量的一个分量中存储大小信息达到以上目的。 但是,这样做虽然可以解决问题,当你需要传递顶点中的真实法线数据时仍会碰到同样的问题。因此为了保持通用性,你可以创建自己的顶点格式,它可以保存纹理和额外数据。关于自定义顶点格式的更多信息可参加教程5-14。 public struct VertexBillboard { public Vector3 Position; public Vector2 TexCoord; public Vector4 AdditionalInfo; public VertexBillboard(Vector3 Position, Vector2 TexCoord,Vector4 AdditionalInfo) { this.Position = Position; this.TexCoord = TexCoord; this.AdditionalInfo = AdditionalInfo; } public static readonly VertexElement[] VertexElements = new VertexElement[] { new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0), new VertexElement(0, 12, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0), new VertexElement(0, 20, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1), }; public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 4); } 这个格式接受一个额外的叫做Add itionalInfo的Vector4。你将这个Vector4作为另一个TextureCoordinate。要区别这两个TextureCoordinate,请注意最后一个参数索引的区别。这意味着真实的纹理坐标在vertex shader中作为TEXCOORD0,而额外的Vector4作为TEXCOORD1 (见教程5-14)。 然后,使用新定义的VertexBillboarding替换VertexPositionTexture(你可以在XNA Game Studi中使用Ctrl+H进行这个操作)。最后,调整CreateBBVertices方法将四个额外的float值添加到顶点中: private void CreateBBVertices() { billboardVertices = new VertexBillboard[billboardList.Count * 6]; int i = 0; foreach (Vector4 currentV4 in billboardList) { Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z); billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 1), new Vector4(0.8f, 0.4f, 0, 0)); billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 0), new Vector4(0.8f, 0.4f, 0, 0)); billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 0), new Vector4(0.8f, 0.4f, 0, 0)); billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 1), new Vector4(0.8f, 0.4f, 0, 0)); billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 1), new Vector4(0.8f, 0.4f, 0, 0)); billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 0), new Vector4(0.8f, 0.4f, 0, 0)); } } 在本例中,你可以改变让billboard的大小。Vector4中的第一个参数,这里是0.8f,用来缩放billboard的宽度,第二个参数,这里是0.4f,缩放高度。这会导致billboard的宽度是高度的两倍。 以上是XNA代码;你要做的就是在vertex shader中将这个Vector4作为TEXCOORD1: BBVertexToPixel SpheBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1) 现在你可以以inExtra 变量的形式访问Vector4,使用它缩放billboard的宽和高: finalPosition += (inTexCoord.x-0.5f)*sideVector*inExtra.x; finalPosition += (0.5f-inTexCoord.y)*upVector*inExtra.y; 这个例子只使用了float4的x和y值,如果需要你也可以访问z和w值。 译者注:在http://creators.xna.com/en-US/sample/billboard上也有一个示例可供研究,比较复杂,使用内容管道生成billboard图像,在HLSL中还实现了风吹动的效果。

处理2D图像和纹理——扩展图像内容处理器:灰度变换和处理器参数

clock 十二月 4, 2010 14:11 by author alex
扩展图像内容处理器:灰度变换和处理器参数 问题 你想扩展图像处理器用来处理每个独立像素的颜色。你还想从XNA主项目中改变处理器的参数。 解决方案 在前面的教程中,你知道了如何扩展内容处理器,让你可以处理并改变独立像素的值。 在Processor类中声明的所有公共变量都可以在素材的属性面板中设置。 工作原理 开始的初始化步骤已经在教程3-9中解释过了。复制下列代码让你可以处理图像文件中的所有图像(有些图像文件,例如texCube,包含多个图像): namespace GrayContentPipeline { [ContentProcessor(DisplayName = "GrayScaleProcessor")] public class ExtentedTextureProcessor : TextureProcessor { public override TextureContent Process(TextureContent input, ContentProcessorContext context) { TextureContent texContent = base.Process(input, context); texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>)); for (int face = 0; face < input.Faces.Count; face++) { MipmapChain mipChain = input.Faces[face]; for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++) { ... } } return texContent; } } } 在内部的循环中你处理了图像的所有face。首先将当前face转化为一个PixelBitmapContent对象让你可以独立地处理每个像素。你可以立即创建第二个PixelBitmapContent对象,在第二个中存储新的颜色: PixelBitmapContent<Color> oldImage = (PixelBitmapContent<Color>)input.Faces[face][mipLevel]; PixelBitmapContent<Vector4> grayImage = new PixelBitmapContent<Vector4>(oldImage.Width, oldImage.Height); 注意:对于这个例子来说,你可以简单地用新的颜色值覆盖旧的颜色值。但是,对于很多图像处理技术来说,为了计算像素的新颜色,你往往还需要访问周围像素的原始颜色,如果你能从旧图像分离出一张新图像就不会有任何问题。 如你所见,你可以自由地定义你想接受的颜色格式。本例中,你指定想要读取的原始颜色的格式是Color,但制定新颜色格式为Vector4。 下一步,你遍历图像的所有像素并获取原始颜色: for (int x = 0; x < oldImage.Width; x++) for (int y = 0; y < oldImage.Height; y++) { Color oldColor = oldImage.GetPixel(x, y); } 一旦知道了原始颜色,你就可以定义对应的灰度颜色了。不要简单的将三个颜色通道取平均值,因为人眼对绿色更加敏感。而且每个颜色通道使用一个介于0至255之间的整数表示的,但是,当你创建一个新颜色时,你需要使用介于0与1之间的浮点数表示颜色值。这意味着你需要将初始颜色值除以255: Color oldColor = oldImage.GetPixel(x, y); float grayValue = oldColor.R * 0.299f / 255.0f; grayValue += oldColor.G * 0.596f / 255.0f; grayValue += oldColor.B * 0.211f / 255.0f; float alpha = oldColor.A / 255.0f; Vector4 grayColor = new Vector4(grayValue, grayValue, grayValue, alpha); grayImage.SetPixel(x, y, newColor); 这可以将图像的所有像素替换为对应的灰度颜色。在两次循环后,确保将新颜色复制到当前的图像face中: input.Faces[face][mipLevel] = grayImage; 调试内容处理器 你常常需要调试内容管道。这比调试普通的项目难,因为在编译时往往会忽略断点,你不得不手动将一个调试器连接到内容项目中。例如,你想调试项目中的图像中心的像素,可以使用以下代码: if ((x == oldImage.Width / 2) && (y == oldImage.Height / 2)) { System.Diagnostics.Debugger.Launch(); } 当编译项目时,Visual Studio会提醒你指定使用哪个调试器,选择New Instance of Visual Studio。 一旦调试器开始运行,你可以在任何你想插入断点的地方调用System. Diagnostics. Debugger. Break( )。 定义处理器参数 通过在中添加共有变量你可以让你的处理器变得更加灵活。例如,将下列代码添加到Processor 类的顶部: public float interpolation = 0.8f; 当你编译项目时,你可以从XNA主项目中设置这个值,如图3-15所示。 图3-15 设置自定义处理器的参数 你可以将这个变量用在内容处理器的任何地方。本例中,我们可以使用这个变量在普通的带颜色的图像和新的灰度图像间进行插值,如果interpolation的值为0,则将带颜色的像素存储在新图像中,如果这个值为1,则存储灰度图像。 Vector4 grayColor = new Vector4(grayValue, grayValue, grayValue, alpha); Vector4 origColor = oldColor.ToVector4(); Vector4 newColor = Vector4.Lerp(origColor, grayColor, interpolation); grayImage.SetPixel(x, y, newColor); 代码 下面是灰度图像处理器的完整代码: namespace GrayContentPipeline { [ContentProcessor(DisplayName = "GrayScaleProcessor")] public class ExtentedTextureProcessor : TextureProcessor { private float interpolation = 0.8f; public float Interpolation { get { return interpolation; } set { interpolation = value; } } public override TextureContent Process(TextureContent input, ContentProcessorContext context) { TextureContent texContent = base.Process(input, context); texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>)); for (int face = 0; face < input.Faces.Count; face++) { MipmapChain mipChain = input.Faces[face]; for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++) { PixelBitmapContent<Color> oldImage = PixelBitmapContent<Color>)input.Faces[face][mipLevel]; PixelBitmapContent<Vector4> grayImage = new PixelBitmapContent<Vector4> (oldImage.Width, oldImage.Height); for (int x = 0; x < oldImage.Width; x++) for (int y = 0; y < oldImage.Height; y++) { Color oldColor = oldImage.GetPixel(x, y); float grayValue = oldColor.R * 0.299f / 255.0f; grayValue += oldColor.G * 0.596f / 255.0f; grayValue += oldColor.B * 0.211f / 255.0f; float alpha = oldColor.A / 255.0f; Vector4 grayColor = new Vector4(grayValue,grayValue, grayValue, alpha); Vector4 origColor = oldColor.ToVector4(); Vector4 newColor = Vector4.Lerp(origColor, grayColor, interpolation); grayImage.SetPixel(x, y, newColor); } input.Faces[face][mipLevel] = grayImage; } } return texContent; } } }

处理2D图像和纹理——扩展图像内容处理器

clock 十二月 4, 2010 13:35 by author alex
扩展图像内容处理器 问题 你想扩展默认的图像内容导入器控制像素,或者你想学习内容管道(content pipeline)。 解决方案 因为XNA已经提供了一个内容导入器将一个图像文件作为源并最终将它创建为一个Texture2D对象,你要做的只是扩展这个内容导入器。本教程中,你可以调用PixelBitmapContent辅助类的ReplaceColor方法,它是由内容管道框架提供的。 注意:如果你只对alpha颜色感兴趣,图像内容处理器会自动将品红色的像素变为透明。本教程主要是介绍如何扩展内容导入器。 工作原理 本教程主要介绍内容管道和如何扩展已经存在的内容处理器,所以,对内容管道的工作流程有所认识是很重要的,如图3-5所示。 内容(素材)管道(Content Pipeline) 在可以使用一个图像文件前,你需要首先加载它,加载过程包括读取字节,选择有用的数据,如有必要还要解压缩数据。 其他素材如3D模型也是如此,模型的数据从一个文件加载后,经过大量的数据处理,基于这些数据创建一个模型对象。 整个处理过程由磁盘上的一个文件开始,最终生成XNA可用的由内容管道管理的对象。事实上,每种素材都有各自的内容管道,如图3-5所示,一个完整的内容管道是由导入器 (importer),处理器(processor),串行化器(serializer)和反串行化器(deserializer)组成的。 图3-5 内容管道工作流 在编译时(按F5可以进行编译),源文件会从磁盘读取,它的内容会被处理,最终结果会被串行化至一个. xnb二进制文件。你可以在. exe 文件所在目录的Content子目录下找到这些.xnb文件。当游戏运行时,这个. xnb二进制文件会被反串行化,这样所有有用的信息变得可用而无需进一步的处理。这种方法最明显的优点是所有处理过程只需进行一次(在编译时),而不是在游戏运行时每一次都要进行处理。第二个优点是. xnb文件是平台无关的,你可以同时用在PC和Xbox 360平台上。 让我们分析一下编译过程,因为这个过程在每次编译项目时都会执行,此过程分成三个子过程: 导入器:读取源文件并提取有用的数据,这个数据是存储在一个指定的标准格式中的。例如对一个模型,标准格式是NodeContent对象,对一个图像,标准格式是TextureContent 对象。这种标准格式叫做DOM对象,你可以在表3-1中看到一个默认DOM对象的表格。 处理器:处理包含在DOM对象中的数据并生成在游戏中可用的对象。例如对模型来说,处理器可以添加法线数据,计算切线,设置effect等。 串行化器或TypeWriter:定义了如何从处理器的输出生成.xnb二进制文件。 这种方法的一个额外优点是当你想在编译过程改变点什么,你可能只需改变其中一个子过程。例如,你想创建一个XNA内置不支持的格式的模型,你只需写一个新的导入器,这个导入器读取文件并创建一个NodeContent对象,你可以把其他工作留给默认的内容管道组件,因为NodeContent 的默认处理器会从那儿获取你的对象。 在运行时,只有一个小过程需要被执行: 反串行化器或TypeReader:定义了如何从存储在.xnb文件的二进制数据流中构建游戏对象。因为这里不需要处理计算,相对于编译过程来说这个过程几乎不花费时间。 XNA自带了很多默认内容导入器和处理器。组合TextureImporter和TextureProcessor,你可以导入几乎任何图像格式。组合Ximporter或FbxImporter和ModelImporter,你可以导入. x 或. Fbx模型。 注意:分离导入器和处理器被证明很有用。Ximporter和FbxImporter都能从磁盘导入数据并将它们格式化为一个简单的NodeContent对象,这两种情况中都传递到ModelProcessor,由ModelProcessor进行繁重的工作。 表3-1 XNA Framework中的默认内容导入器和处理器   XNA内容管道带有在编译时将这些对象写入二进制文件的默认串行化器和运行时从一个二进制文件重建对象的反串行化器。化器。 使用默认内容管道框架的组件 写入/扩展一个内容处理器的关键是尽可能地复用已经存在于内容管道中的组件。 本教程中,你将扩展默认TextureProcessor使你可以在图像数据加载到XNA项目之前对它做出改变。在编译时,想从文件读取图像,将内容存储在一个2D数组中形成图像,你还想改变一些像素并将结果放在一个.xnb文件中。 当运行程序时,.xnb文件会从文件读取,并包含了你施加的改变。 首先,从磁盘读取文件并将它们转换为一个2D的颜色数组(无论图像文件的格式是什么),你应该使用默认的导入器。 然后,你需要扩展TextureProcessor。因为本教程关心的是如何设置一个自定义的内容管道,所以你只需将所有的黑色变成白色。更高级的应用请见下一个教程。 你要确保处理器的输出是一个TextureContent对象,这样你可以使用默认的串行化器将它保存为. xnb文件并可以使用默认反串行化器加载这个. Xnb文件。 图 3-6显示了整个过程。找到你将扩展的处理器,和从XNA中借用的默认组件。 图3-6 你将重写的内容管道处理器的位置 本书中,每次当你处理内容管道时我都会显示一张类似于图3-6的图片,这样你可以清楚地知道你将自己处理哪一部分。扩展一个已存在的内容处理器要扩展一个已存在的内容处理器,需要先进行几个步骤。虽然这些步骤对于一个有经验的.NET 程序员来说是很简单的,我还是将它们罗列出来,以后的教程中你可以参考这些步骤,本章接下来的部分会解释这些步骤。 在解决方案中添加一个新的内容管道项目。 在新项目中,添加对Microsoft.XNA. Framework.Content. Pipeline的引用。 添加管道命名空间。 指定要扩展的部分(即你要复写的方法)。 编译新的内容管道项目。 在主项目中添加新建的这个项目的引用。 对素材选择新建的处理器。 设置项目依赖项。 初始化所有东西后,在第4步中创建的方法中编写代码。 在解决方案中添加一个新的内容管道项目 要扩展或创建一个新的内容导入器/处理器,你需要在解决方案中添加一个新项目。右击你的解决方案选择Add→New Project,如图3-7所示。在弹出的对话框中,选择Content Pipeline Extension Library并起一个合适的名称,如图3-8所示。 图3-7 在解决方案中添加一个新项目 图3-8 创建一个content pipeline extension library 项目中有一个新文件,包含了你定义的命名空间,还包括一个默认的Processor类。新项目已经添加到了解决方案中,如图3-9所示。 图3-9 添加到解决方案中的Content pipeline项目 添加对Microsoft.XNA.Framework.Content.Pipeline的引用 在新项目中,确保对Microsoft. XNA. Framework. Content. Pipeline (version 2.0.0.0)的引用。打开Project菜单选择Add Reference。从列表中添加正确的引用,如图3-10所示。 图3-10 选择XNA pipeline引用 使用代码块添加Pipeline的命名空间 你还要使编译器链接到新的命名空间,添加下列代码块: using System.IO; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Graphics; using Microsoft.Xna.Framework.Content.Pipeline.Processors; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler; 指定要扩展的部分 文件已经包含了一个ContentProcessor的模板,用以下代码替换这些代码,指定新处理器的输入和输出。所有这些自定义处理器使用它的基类(默认纹理处理器)处理输入,所以这个新ContentProcessor工作方式与默认纹理处理器是一样的: namespace MyImagePipeline { [ContentProcessor(DisplayName = "ExtendedExample")] public class ExtentedTextureProcessor : TextureProcessor { public override TextureContent Process(TextureContent input, ContentProcessorContext context) { return base.Process(input, context); } } } 本教程中我们将扩展默认TextureProcessor,所以你需要从TextureProcessor类继承。由于这个原因,你的Process方法接受一个TextureContent对象并将一个TextureContent对象作为输出。 等一会儿你将编写一个真正的Process方法,但现在这个方法只从默认TextureProcessor类继承。 注意:ExtendedExample是你的新自定义处理器的名称。如果你省略了DisplayName标签,你的处理器会使用类的名称,本例中是ExtendedTextureProcessor。 注意:请确保将这个DisplayName放在[ContentProcessor]中。如果不是,XNA不会把这个类当成内容处理器。结果是当浏览可用内容处理器列表时你的处理器不会显示。 编译新的内容管道项目 在下一步你要将这个新项目的引用添加到主项目中,所以你需要首先编译这个项目。按F6可以进行编译,而按F5会编译并运行整个解决方案。 注意:这个编译过程要花几秒钟的时间;你可以在窗口的左下角看到这个处理过程的信息。 将新建的引用添加到主程序中 编译了自定义处理器后,你还需要将它应用到主程序中。在解决方案中找到Content,右击References并选择Add Reference,如图3-11所示。 图3-11 添加到自定义内容管道项目的引用 在弹出的对话框中,找到Projects选项卡,并从列表中选择你的内容管道项目,如图3-12所示。 图3-12 选择内容管道项目 注意:如果内容管道项目不在列表中,你肯定遗漏了前面的编译步骤。 选择新建的处理器处理一个图像文件 当你将一个图像文件导入到项目中时,你可以选择新建的内容处理器处理这个图像文件,如图3-13所示。 图3-13 选择自定义的内容处理器处理图像 现在当你编译项目时,你的自定义处理器就可以用来处理图像文件了! 设置项目依赖项 你对自定义内容处理器进行的每次改变都需手动重新编译项目。要解决这个问题,你可以让主项目依赖于内容管道项目,这样当你每次重新编译主项目时,内容管道项目会首先编译(如果在上次编译后你进行了某些改变)。你可以通过右击主项目选择Project Dependencies设置依赖项。在弹出的对话框中,你需要选择主程序依赖于第二个项目,如图3-14所示。这样,当你按F5编译主程序时,内容管道项目的. dll文件会首先被编译。 图3-14 选择项目依赖项 扩展默认纹理处理器 现在所有东西都进行了初始化,你可以编写自定义代码了。 你已经创建了一个ExtendedTextureProcessor类,这个类从默认的TextureProcessor类继承,你也声明了将复写Process方法。所有内容处理器这个方法都叫做Process并接受一个DOM 对象(在TextureProcessor的情况中是TextureContent对象,如表3-1所示) 和一个ContentProcessorContext对象作为参数。这个context对象用来创建多个生成(nested builds)。例如,当导入一个Model时,这个对象会包含所有纹理文件的名称,这些名称也需要和模型一起被加载,在教程4-12可学到更多这方面的知识。 如表格3-1所示,在TextureProcessor情况中,你需要返回一个TextureContent对象。这种情况中纹理作为处理器的输入和输出,现在的处理器只是将输入传递到基类中: [ContentProcessor(DisplayName = "ExtendedExample")] public class ExtentedTextureProcessor : TextureProcessor { public override TextureContent Process(TextureContent input, ContentProcessorContext context) { return base.Process(input, context); } } 这一步使用TextureProcessor 类的Process方法处理输入(因为你从默认的TextureProcessor 类继承而来)并返回结果图像,所以这也做好了扩展这个处理过程的准备。直到现在,你只是简单地将输入直接传递到输出,所以你的处理器会获得和默认TextureProcessor同样的结果。 注意:这种情况下的TextureProcessor是特殊的,因为输入和输出对象的类型是一样的。在更复杂的情况下,你想让base. Process方法可以将输入对象转换为一个输出模型。例如,如果你扩展了模型处理器,你会首先让base. Process方法将输入的NodeContent对象转换为一个ModelContent对象,这包含大量的工作。一旦你有了默认的ModelContent对象,你就可以方便地扩展/改变它们。可见教程4-12学习一个扩展模型处理器的例子。 在本教程中,你不想让输入立即传递到输出,你想改变一些颜色值。使用标准DOM对象的一个好处是你可以使用已经定义好的默认内容管道。例如,TextureContent类有一个有用的ConvertBitmapType方法可以改变纹理内容的格式,下面的代码改变了texContent 对象内容的颜色: TextureContent texContent = base.Process(input, context); texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>)); 因为TextureContent类是抽象类,如果是一个2D图像,texContent对象会实例化为一个TextureContent2D对象(也可以是一个TextureContent3D或TextureContentCube对象)。这意味着一张图像可以有多个face和多个mipmap (可见教程3-7了解mipmap的知识)。下面的代码选择第一个face和第一个mipmap,一个简单的2D纹理只有一个face一个mipmap level。 PixelBitmapContent<Color> image = (PixelBitmapContent<Color>)input.Faces[0][0]; PixelBitmapContent类有一个有用的ReplaceColor方法,这个方法可以将图像中指定颜色替换成另一个颜色: Color colorToReplace = Color.Black; image.ReplaceColor(Color.Black, Color.White); 这就是你的自定义处理器所做的工作,最后返回TextureContent对象: return texContent; 在编译过程中,这个对象会被传递到串行化器,串行化器将这个对象保存为二进制文件。在运行过程中这个二进制文件有反串行化器加载并创建一个Texture2D对象。现在确保在XNA项目中导入一个图像,选择自定义处理器处理这张图像,并在LoadContent方法中加载这个Texture2D对象: myTexture = Content.Load<Texture2D>("image"); 多个Face/Mipmap 本例中你从TextureContent处理器继承,这个处理器会生成mipmap (可以代替SpriteTextureProcessor)。关于mipmap的更多信息可见教程3-7。如果导入的图像是一张立方图像,那么这个纹理有六个面。要使教程完整,还需要遍历face和mipmap的代码: for (int face = 0; face < texContent.Faces.Count; face++) { MipmapChain mipChain = texContent.Faces[face]; for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++) { PixelBitmapContent<Color> image = (PixelBitmapContent)input.Faces[face][mipLevel]; image.ReplaceColor(Color.Black, Color.White); } } Faces属性的第一个索引是图像的face。标准2D图像只有一个face,而立方纹理有六个 face。第二个索引是mipmap level,不使用mipmapp的图像只有一个level。这个代码对纹理的每个face的每个mipmap level将你选择的颜色(本例中是黑色)变成白色(译者注:实际代码中是将小女孩蓝色的头发替换成黄色)。 代码 下面是扩展默认内容处理器的所有代码,记住要使代码正常工作,你需要在新项目中添加对Microsoft.XNA. Framework. Content. Pipeline的引用。 using System; using System.Collections.Generic; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Graphics; using Microsoft.Xna.Framework.Content.Pipeline.Processors; using System.IO; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler; namespace MyImagePipeline { [ContentProcessor(DisplayName = "ExtendedExample")] public class ExtentedTextureProcessor : TextureProcessor { public override TextureContent Process(TextureContent input, ContentProcessorContext context) { TextureContent texContent = base.Process(input, context); texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>)); for (int face = 0; face < texContent.Faces.Count; face++) { MipmapChain mipChain = texContent.Faces[face]; for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++) { PixelBitmapContent<Color> image = (PixelBitmapContent<Color>) input.Faces[face][mipLevel]; image.ReplaceColor(Color.Black, Color.White); } } return texContent; } } }

处理2D图像和纹理——将场景绘制到纹理

clock 十二月 4, 2010 13:26 by author alex
将场景绘制到纹理 问题 你想将屏幕上的内容保存在一个纹理文件中,这样可以实现屏幕截图,或者将场景绘制到一个纹理,而这个纹理用于深度贴图/折射贴图或作为post-processing effect的输入。 解决方案 最简单的方法是使用device. ResolveBackBuffer方法,这个方法将当前后备缓冲中的内容写入到一个纹理中。如果你只想将场景绘制到一个纹理,或指定大小的纹理,你需要使用device.SetRenderTarget 方法将渲染目标从屏幕变为你在内存中定义的那个目标。一旦场景已经完整地绘制到了那个目标,你就可以将它的内容存储在一张纹理中了。 工作原理 最简单的方法是将场景绘制到屏幕并将后备缓冲中的内容复制到ResolveTexture2D中。这样你首先要创建一个ResolveTexture2D对象用来存储后备缓冲的内容。这意味着这个ResolveTexture2D对象的大小和格式要和后备缓冲一样。在LoadContent方法中添加以下代码创建一个ResolveTexture2D对象: PresentationParameters pp = device.PresentationParameters; resolveTexture = new ResolveTexture2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format); 第一行代码获取当前PresentationParameters,这个PresentationParameters包含了关于图像设备当前配置的所有细节,这些细节包括当前宽度、高度和后备缓冲的数据格式。第二行代码基于PresentationParameters创建了一个ResolveTexture2D对象。创建完ResolveTexture2D对象后,你就可以使用它存储当前后备缓冲的内容了。在Draw方法中,在你想提取后备缓冲的地方加入以下代码: device.ResolveBackBuffer(resolveTexture); resolveTexture.Save("output.bmp", ImageFileFormat.Bmp); 上面的代码会将当前后备缓冲的内容存储在resolveTexture变量中。因为ResolveTexture2D是从Texture2D类继承的,你可以把这个ResolveTexture2D当成普通的Texture2D处理。在前面的例子中,我们将这个纹理保存为一个文件。 注意:因为硬件限制,在调用完device. ResolveBackBuffer方法后,后备缓冲中的内容会被丢弃! 使用与屏幕一样大小的自定义渲染目标(Render Target) 你可以自定义一个RenderTarget2D并在绘制前activate这个RenderTarget2D,而不是绘制到后备缓冲。渲染目标——顾名思义:是一段可以用来绘制其上的内存。当所有绘制完成后,你再次将渲染目标的内容复制到一个纹理中。添加一个变量: RenderTarget2D renderTarget; 然后在LoadContent方法中将它实例化: PresentationParameters pp = device.PresentationParameters; renderTarget = new RenderTarget2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format); 你定义了渲染目标的宽和高,指定了mipmap的级别为1(见教程3-6),设置了数据格式。在这个例子中,你使用和后备缓冲相同的值。 注意:mipmap只有在纹理的宽/高是2的整数幂时才能执行,当你指定其他大于1的mipmap发生错误时,原因往往就是纹理尺寸不是2的整数幂。 当你初始化渲染目标后,你可以在Draw方法中使用下面的代码将它设置为active渲染目标。在这行代码之后的所有东西都会绘制到这个自定义的渲染目标: device.SetRenderTarget(0, renderTarget); 你需要在Draw方法调用Clear前加入这行代码,因为渲染目标的内容需要在绘制其他东西前进行清除。然后,你绘制整个场景,当场景绘制完后,将渲染目标的内容复制到一张纹理。在这之前,你还要通过activating另一个渲染目标deactivate这个渲染目标。在这个代码中,你通过将第二个参数设置为null将后备缓冲作为渲染目标: device.SetRenderTarget(0, null); Texture2D resolvedTexture = renderTarget.GetTexture(); 既然你已经activat默认的后备缓冲,那么之后渲染的所有东西又和以前一样被绘制到屏幕上了。 最后一行代码将自定义渲染目标中的内容载入到resolvedTexture变量中。 设置一个尺寸不等于屏幕大小的自定义渲染目标 你也可以选择绘制到一个与屏幕大小不同的渲染目标。这个很有用,例如可以用在post-processing effect的中间过程中。在模糊、intensity和边缘检测shader中可以使用一张小一点的图像减轻显卡的负担。 如果长宽比例与窗口相同,这不是问题。如果你想设置一个长宽比和窗口不同的渲染目标,你的投影矩阵对渲染目标来说将不正确,因为这个投影矩阵是使用窗口长宽比创建的(见教程2-1),所以你需要定义一个新的对应渲染目标的投影矩阵: PresentationParameters pp = device.PresentationParameters; int width = pp.BackBufferWidth / 2; int height = pp.BackBufferHeight / 4; renderTarget = new RenderTarget2D(device, width, height, 1,device.DisplayMode.Format); rendertargetProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, (float)width / (float)height, 0.5f, 100.0f); 首先你定义了渲染目标的分辨率并创建了对应的渲染目标和投影矩阵。新的渲染目标的宽设置为窗口宽的一半,高为四分之一,这样渲染目标的长宽比与窗口是不同的。如果你还是使用老的投影矩阵,那么场景看起来好像是在竖直方向被压扁了。现在只要你是在把场景渲染到渲染目标内,你就得使用rendertargetProjectionMatrix取代对应窗口的Projection矩阵。代码初始化一个RenderTarget2D对象并设置为你想要的分辨率。注意如果你将内容保存在纹理中,这个纹理的大小必须和渲染目标的大小是一样的。 protected override void LoadContent() { device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null); cCross = new CoordCross(device); spriteBatch = new SpriteBatch(device); PresentationParameters pp = device.PresentationParameters; int width = pp.BackBufferWidth / 2; int height = pp.BackBufferHeight / 4; renderTarget = new RenderTarget2D(device, width, height, 1, device.DisplayMode.Format); rendertargetProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, (float)width / (float)height, 0.5f, 100.0f); } 在Draw方法中,你activate自定义渲染目标,将场景渲染其中,然后deactivate这个渲染目标,保存其中的内容到一个纹理。注意使用rendertargetProjectionMatrix将3D物体绘制到渲染目标。而这个纹理也可以使用SpriteBatch显示在屏幕上: protected override void Draw(GameTime gameTime) { device.SetRenderTarget(0, renderTarget); device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0); cCross.Draw(fpsCam.ViewMatrix, rendertargetProjectionMatrix); device.SetRenderTarget(0, null); Texture2D resolvedTexture = renderTarget.GetTexture(); graphics.GraphicsDevice.Clear(Color.Tomato); spriteBatch.Begin(); spriteBatch.Draw(resolvedTexture, new Vector2(100, 100), Color.White); spriteBatch.End(); base.Draw(gameTime); }

处理2D图像和纹理——创建一张纹理,定义每个像素的颜色,将纹理保存到一个文件

clock 十一月 26, 2010 10:53 by author alex
问题 你想创建一张新纹理并手动定义每个像素的颜色。当你想让用户创建一个新图像或生成诸如深度贴图之类的人工图像时是很有用的。 你想将这张纹理存储到文件中,例如,生成游戏截图或为了调试的目的。 解决方案 设置一张图像的颜色和将纹理用你选择的格式保存到一个文件中被XNA Framework直接支持。 你可以通过调用纹理的SetData方法改变它的内容,这个方法以一个包含每个像素颜色值的数组为参数。 你可以使用纹理的Save方法将它保存到一个文件中。 工作原理 首先创建一个数组,保存图像中每个像素的颜色: int textureWidth = 512; int textureHeight = 512; Color[] textureColors = new Color[textureWidth* textureHeight]; int i = 0; for (int ver = 0; ver < textureHeight; ver++) for (int hor=0; hor<textureWidth; hor++) { float red = (float)hor / (float)textureWidth; float green = 0; float blue = (float)ver / (float)textureHeight; float alpha = 1; textureColors[i++] = new Color(new Vector4(red, green, blue, alpha)); } 上述代码首先创建一个可以为一个分辨率为512×512的纹理存储颜色的数组。然后使用两个for循环分别填充每个像素。使用这个填充顺序,你首先从左上角开始,沿着从左向右,从上到下的顺序遍历像素。 最后,你需要实际创建纹理并将数组中的内容加载到纹理内存中: Texture2D newTexture = new Texture2D(device, textureWidth, textureHeight, 1, TextureUsage.None, SurfaceFormat.Color); 然后,在device和图像分辨率之后,你需要指定mipmap的数量(见后面的注意事项)。下一个参数让你可以设置TextureUsage。通过选择TextureUsage.AutoGenerateMipMap,XNA会自动生成你指定的mipmaps的数量。记住,如果你想使用mipmap,图像的宽和高必须是2的整数幂。 注意:一个mipmapped图像包含图像的多个分辨率,这在很多情况下是很有用得。当绘制一个3D场景时,如果一个带纹理的盒子距相机如此之远以至于在窗口中只有一个像素的大小。要确定盒子的颜色,显卡仍需要计算从哪个纹理坐标采样纹理。当稍微移动一下相机,纹理坐标可能会有少许不同,但可能对应纹理中一个截然不同的颜色。结果是,稍微一动一个相机也会导致盒子的颜色发生变化,导致屏幕上像素的闪烁。一个解决方法是在图像中存储不同分辨率的多个版本。对一个64 × 64像素的图像,如果你开启mipmapping,XNA还会在图像中创建32 × 32,16 × 16,8 × 8,4 × 4,2 × 2直到1 × 1的版本, 1 × 1图像的单个像素就是整个纹理的颜色。现在,在刚才很远的盒子的情况中,XNA会使用图像的1 × 1。这样,纹理坐标的变化不会导致颜色的变化,闪烁的像素会保持一个不变的颜色! 最后一个参数指定了纹理中像素的格式,这对XNA分配足够的内存存储纹理是必须的。 而且,当获取纹理中的值时,这也是你预期的格式,当你将数据写入到纹理时,必须提供这个格式的数据。 最后,只需简单地将Color数组存储在纹理中: newTexture.SetData<Color>(textureColors); 将纹理保存到文件还要简单: myTexture.Save("savedtexture.jpg", ImageFileFormat.Jpg); 最后一个参数指定文件的压缩格式,确保文件扩展名对应这个格式,否则试图从一个外部图像浏览器打开这个文件会遇到麻烦。 当运行这个代码时,文件会创建到与可执行文件相同的位置中。默认是在\bin\x86\Debug文件夹中。 代码 下面的代码创建一个纹理,使用颜色数据进行填充并返回这个纹理: private Texture2D DefineTextureColors() { int textureWidth = 512; int textureHeight = 512; Color[] textureColors = new Color[textureWidth* textureHeight]; int i = 0; for (int ver = 0; ver < textureHeight; ver++) for(int hor=0; hor<textureWidth; hor++) { textureColors[i++] = new Color(new Vector4((float)hor / (float)textureWidth, 0, (float)ver / (float)textureHeight, 1)); } Texture2D newTexture = new Texture2D(device, textureWidth, textureHeight, 1, ResourceUsage.None, SurfaceFormat.Color); newTexture.SetData<Color>(textureColors); return newTexture; } 这个方法需要从LoadContent方法中调用,因为它需要设备实例化: protected override void LoadContent() { device = graphics.GraphicsDevice; spriteBatch = new SpriteBatch(GraphicsDevice); myTexture = DefineTextureColors(); myTexture.Save("savedtexture.jpg", ImageFileFormat.Jpg); }

处理2D图像和纹理——创建2D菜单界面

clock 十一月 26, 2010 10:51 by author alex
问题 你想创建一个2D菜单界面,让你可以容易地添加新的菜单和指定它们的菜单选项。这个菜单允许用户使用控制器/键盘切换不同的选项和菜单,当用户从一个菜单切换到另一个菜单时还可以定义漂亮的过渡效果。 解决方案 你将创建一个新的类,MenuWindow,这个类保存所有与菜单相关的东西,诸如菜单的当前状态,菜单项,背景图像等。这个类让主程序可以容易地创建多个MenuWindow实例并将菜单项添加到这些实例中。菜单项可以使用你的系统中安装的字体用2D文字的方式显示。 要实现过渡效果,一个窗口将拥有Starting、Ending、Active和Inactive状态。controller/keyboard状态会被传递到Active MenuWindow,可以通过传递选择的菜单让主程序知道用户是否选择了一个菜单项。 让MenuWindow可以存储和显示背景图像可以增强最终效果,这还可以使用后期处理效果加以改进(见教程2-12)。 工作原理 主程序会创建几个MenuWindow对象,每个菜单项都会链接到另一个MenuWindow对象。所以首先定义一个MenuWindow类。 MenuWindow类创建一个叫做MenuWindow的新类。每个菜单能够存储它的菜单项。对每个菜单项,必须存储文字和指向的菜单。所以,在MenuWindow类中定义下述结构: private struct MenuItem { public string itemText; public MenuWindow itemLink; public MenuItem(string itemText, MenuWindow itemLink) { this.itemText = itemText; this.itemLink = itemLink; } } 每个菜单总是处于下面四个状态之一: Starting:菜单刚刚被选择,正在淡入。 Active:此菜单为屏幕上显示的唯一一个菜单并处理用户输入。 Ending:此菜单中的一个菜单项被选择,因此它正在淡出。 Inactive:如果此菜单不在前面三个状态之中,那么它不被绘制。 所以你需要一个枚举表示状态,枚举应放在类的外部: public enum WindowState { Starting, Active, Ending, Inactive } 然后是使类正常工作所需的变量: private TimeSpan changeSpan; private WindowState windowState; private List<MenuItem> itemList; private int selectedItem; private SpriteFont spriteFont; private double changeProgress; changeSpan表示淡入淡出持续的时间。然后你需要一些变量保存菜单的当前状态、菜单项的集合和当前选择的菜单项。变量changeProgress保存一个介于0和1之间的值表示在Starting或Ending状态时淡入淡出处在过程的何处。 构造函数只是简单地初始化这些变量: public MenuWindow(SpriteFont spriteFont) { itemList = new List<MenuItem>(); changeSpan = TimeSpan.FromMilliseconds(800); selectedItem = 0; changeProgress = 0; windowState = WindowState.Inactive; this.spriteFont = spriteFont; } 你指定了两个菜单间的过渡持续800毫秒的时间,菜单开始时的状态为Inactive。你可以在前一个教程中学到SpriteFont类和如何绘制文字。 然后你需要一个方法添加菜单项: public void AddMenuItem(string itemText, MenuWindow itemLink) { MenuItem newItem = new MenuItem(itemText, itemLink); itemList.Add(newItem); } 当用户选择菜单项时,菜单项上的文字和菜单需要被激活,被主程序传递。一个新的菜单项被创建并被添加到itemList中。你还需要一个方法激活一个Inactive菜单: public void WakeUp() { windowState = WindowState.Starting; } 像XNA程序中的大多数组件一样。这个类需要被更新: public void Update(double timePassedSinceLastFrame) { if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending)) changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds; if (changeProgress >= 1.0f) { changeProgress = 0.0f; if (windowState == WindowState.Starting) windowState = WindowState.Active; else if (windowState == WindowState.Ending) windowState = WindowState.Inactive; } } 这个方法接受上一个update调用以来经历的毫秒数为参数(通常这个参数为1000/60 毫秒,见教程1-5)。如果菜单正在过渡模式中,变量changeProgress进行更新,导致在经过了存储在changeSpan (800,前面你已经定义了)中的毫秒后,这个值达到1。 当这个值达到1,过渡结束,状态要么从Starting变换到Active,要么从Ending变换为Inactive。 最后,你需要一些代码绘制菜单。当菜单处于Active状态时,必须显示菜单项,例如从位置(300,300)开始,每个菜单项位于前一个之下30个像素。 当菜单在Starting状态时,菜单项应该淡入(它们的alpha值应该从0增加到1)并且从屏幕的左边移动至最终的位置。如果处于Ending状态,菜单项应该淡出 (它们的alpha值应该减少)并且移动到右方。 public void Draw(SpriteBatch spriteBatch) { if (windowState == WindowState.Inactive) return; float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress); int verPosition = 300; float horPosition = 300; float alphaValue; switch (windowState) { case WindowState.Starting: horPosition -= 200 * (1.0f - (float)smoothedProgress); alphaValue = smoothedProgress; break; case WindowState.Ending: horPosition += 200 * (float)smoothedProgress; alphaValue = 1.0f - smoothedProgress; break; default: alphaValue = 1; break; } for (int itemID = 0; itemID < itemList.Count; itemID++) { Vector2 itemPostition = new Vector2(horPosition, verPosition); Color itemColor = Color.White; if (itemID == selectedItem) itemColor = new Color(new Vector4(1,0,0,alphaValue)); else itemColor = new Color(new Vector4(1,1,1,alphaValue)); spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0); verPosition += 30; } } 当处于Starting或Ending状态时,changeProgress值会线性地从0增加到1,它工作正常但无法在开始或结束时产生平滑的效果。MathHelper. SmoothStep方法平滑曲线,让开始和结束时都能平滑过渡。当菜单处于Starting或Ending状态时,case结构中调整菜单项的水平位置和alpha值。然后,对每个菜单项,其上的文字被正确地绘制到屏幕上。绘制文字更多的信息可见前一个教程。如果菜单项没有被选择,文字是白色的,当选择时变为红色。 以上就是MenuWindow类的基础! 在主程序中,你只需一个集合存储所有的菜单: List<MenuWindow> menuList; 在LoadContent方法中,你可以创建菜单并将它们添加到menuList中。然后,你可以将菜单项添加到菜单中,让你可以在用户选择菜单项时指定激活哪个菜单。 MenuWindow menuMain = new MenuWindow(menuFont, "Main Menu", backgroundImage); MenuWindow menuNewGame = new MenuWindow(menuFont, "Start a New Game", bg); menuList.Add(menuMain); menuList.Add(menuNewGame); menuMain.AddMenuItem("New Game", menuNewGame); menuNewGame.AddMenuItem("Back to Main menu", menuMain); menuMain.WakeUp(); 以上操作创建了两个菜单,每个菜单包含一个链接到另一个菜单的菜单项。初始化菜单结构后,激活mainMenu,使它处于Starting状态。现在,你需要在程序更新循环中更新所有菜单: foreach (MenuWindow currentMenu in menuList) currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds); 并在Draw方法中绘制菜单: spriteBatch.Begin(); foreach (MenuWindow currentMenu in menuList) currentMenu.Draw(spriteBatch); spriteBatch.End(); 当运行代码时,主菜单会从左侧淡入,因为你还没有处理用户输入,所以无法切换到其他菜单。 允许用户通过菜单项导航 你将在MenuWindow类中添加一个方法处理用户输入。注意这个方法只能被当前激活的菜单调用: public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState) { if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down)) selectedItem++; if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up)) selectedItem--; if (selectedItem < 0) selectedItem = 0; if (selectedItem >= itemList.Count) selectedItem = itemList.Count-1; if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter)) { windowState = WindowState.Ending; return itemList[selectedItem].itemLink; } else if (lastKeybState.IsKeyDown(Keys.Escape)) return null; else return this; } 这里有许多有趣的东西。首先,你检查up或down键是否被按下。当用户按下一个键时,只要这个键一直被按着,那么这个键的IsKeyUp为true!因此,你需要检查上一帧这个键是否已经被按下。 如果up或down键被按下,你需要对应地改变selectedItem变量。如果超出边界,需要将它返回到一个合理的位置。 接下去的代码包含整个导航逻辑。你应该注意到这个方法需要返回一个MenuWindow对象到主程序中。因为这个方法只会被当前激活的菜单调用,这允许当前菜单将新选择的菜单返回到主程序。如果用户没有选择任何菜单项,当前菜单会保持激活并返回自己,这就是最后一行代码的操作。通过这种方式,主菜单在处理输入后知道哪个菜单是激活菜单。 当用户按下Enter键后,当前激活菜单会从Active转到Ending状态,被选择菜单项链接的菜单被返回到主程序。如果用户按下Escape键,返回null,这回被后面的退出程序捕捉到。如果什么都没选,返回当前菜单自身,告知主程序当前菜单仍保持激活。 这个方法需要从主程序调用,主程序需要两个变量: MenuWindow activeMenu; KeyboardState lastKeybState; 第一个变量保存当前激活的菜单,在LoadContent 方法中进行初始化。lastKeybState 变量在Initialize方法中进行初始化。 private void MenuInput(KeyboardState currentKeybState) { MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); if (newActive == null) this.Exit(); else if (newActive != activeMenu) newActive.WakeUp(); activeMenu = newActive; } 这个方法调用当前激活菜单的ProcessInput方法,并传递前一个和当前的键盘状态。如前所述,如果用户按下Escape键这个方法会返回null,所以在这种情况中,应用程序会退出。否则,如果这个方法返回一个不同于当前菜单的菜单,说明用户做出了一个选择。在这种情况中,新选择的菜单会通过调用它的WakeUp方法从Inactive变化到Starting状态。如果两种情况都不是,则返回当前菜单,存储在activeMenu变量中。 请确保在Update方法内部调用这个方法。运行这个代码让你可以在两个菜单间自由切换。 在菜单中添加标题和背景图像 菜单现在已经可以正常工作了,但没有背景图片菜单还不够完美。在MenuWindow类中添加两个变量: private string menuTitle; private Texture2D backgroundImage; 这两个变量需要在Initialize方法中赋值: public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage) { //... this.menuTitle = menuTitle; this.backgroundImage = backgroundImage; } 显示标题很简单。但是,如果菜单使用不同的背景图片,那么在绘制背景图片时会遇到些麻烦。你想要的是在Active和Ending状态下显示图片。当处于Starting状态时,新的背景会混合在前一个的上面。当在第一张图片上混合第二张图片时,你想要保证第一张图像首先被绘制!这并不容易,因为这涉及到改变菜单的绘制顺序。 一个简单的方法是使用SpriteBatch . Draw方法的layerDepth参数(见教程3-3)。当处于Active或Ending状态时,背景图像会在距离1绘制,即“最深的”层。在Starting模式中,图像会在距离0.5绘制,所有文字会在距离0绘制。当使用SpriteSortMode. BackToFront时,首先在深度1的Active或Ending菜单会被绘制。然后,Starting菜单被绘制(混合在已经存在的图像上),最后绘制所有文字。 在MenuWindow的Draw方法中,创建两个变量: float bgLayerDepth; Color bgColor; 这两个变量保存背景图像的layerDepth和透明颜色值,在switch中设置这两个变量: switch (windowState) { case WindowState.Starting: horPosition -= 200 * (1.0f - (float)smoothedProgress); alphaValue = smoothedProgress; bgLayerDepth = 0.5f; bgColor = new Color(new Vector4(1, 1, 1, alphaValue)); break; case WindowState.Ending: horPosition += 200 * (float)smoothedProgress; alphaValue = 1.0f - smoothedProgress; bgLayerDepth = 1; bgColor = Color.White; break; default: alphaValue = 1; bgLayerDepth = 1; bgColor = Color.White; break; } Color. White与Color(new Vector4(1, 1, 1, 1))相同,表示完全alpha值。如果一个菜单处于Starting或Ending状态,会计算alphaValue。然后,使用透明颜色值绘制标题和背景图像。 Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue)); spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth); spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0); 标题文字被缩放到1.5f,比菜单项的文字大。 最后,你需要确保在主程序的Draw方法中将SpriteSortMode设置为BackToFront: spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); 从菜单移动到游戏 至此你创建了一些漂亮的菜单,但如何创建一些菜单项开始游戏?这可以使用dummy 菜单,这个菜单应该存储在主程序中。例如,如果你想在Start New Game菜单中包含start an Easy,a Normal或a Hard game的菜单项,应该将下列代码添加到菜单中: MenuWindow startGameEasy; MenuWindow startGameNormal; MenuWindow startGameHard; bool menusRunning; 在LoadContent方法中,你可以使用null参数实例化这些变量并将它们链接到menuNewGame中的菜单项上: startGameEasy = new MenuWindow(null, null, null); startGameNormal = new MenuWindow(null, null, null); startGameHard = new MenuWindow(null, null, null); menuNewGame.AddMenuItem("Easy", startGameEasy); menuNewGame.AddMenuItem("Normal", startGameNormal); menuNewGame.AddMenuItem("Hard", startGameHard); menuNewGame.AddMenuItem("Back to Main menu", menuMain); 这会在New Game菜单中添加四个菜单项。接下去你要做的就是检测哪一个dummy菜单被选择。因此,需要扩展一下MenuInput方法: private void MenuInput(KeyboardState currentKeybState) { MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); if (newActive == startGameEasy) { //set level to easy menusRunning = false; } else if (newActive == startGameNormal) { //set level to normal menusRunning = false; } else if (newActive == startGameHard) { //set level to hard menusRunning = false; } else if (newActive == null) this.Exit(); else if (newActive != activeMenu) newActive.WakeUp(); activeMenu = newActive; } 你可以使用menusRunning变量保证当用户在游戏中时不更新/绘制菜单: if (menusRunning) { spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); foreach (MenuWindow currentMenu in menuList) currentMenu.Draw(spriteBatch); spriteBatch.End(); Window.Title = "Menu running ..."; } else { Window.Title = "Game running..."; } 代码 MenuWindow类 下面是简单、必须的方法: public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage) { itemList = new List<MenuItem>(); changeSpan = TimeSpan.FromMilliseconds(800); selectedItem = 0; changeProgress = 0; windowState = WindowState.Inactive; this.spriteFont = spriteFont; this.menuTitle = menuTitle; this.backgroundImage = backgroundImage; } public void AddMenuItem(string itemText, MenuWindow itemLink) { MenuItem newItem = new MenuItem(itemText, itemLink); itemList.Add(newItem); } public void WakeUp() { windowState = WindowState.Starting; } 然后是更新菜单的方法。注意只有当前激活的菜单才会调用ProcessInput方法。 public void Update(double timePassedSinceLastFrame) { if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending)) changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds; if (changeProgress >= 1.0f) { changeProgress = 0.0f; if (windowState == WindowState.Starting) windowState = WindowState.Active; else if (windowState == WindowState.Ending) windowState = WindowState.Inactive; } } public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState) { if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down)) selectedItem++; if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up)) selectedItem--; if (selectedItem < 0) selectedItem = 0; if (selectedItem >= itemList.Count) selectedItem = itemList.Count-1; if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter)) { windowState = WindowState.Ending; return itemList[selectedItem].itemLink; } else if (lastKeybState.IsKeyDown(Keys.Escape)) return null; else return this; } 最后是绘制菜单的方法: public void Draw(SpriteBatch spriteBatch) { if (windowState == WindowState.Inactive) return; float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress); int verPosition = 300; float horPosition = 300; float alphaValue; float bgLayerDepth; Color bgColor; switch (windowState) { case WindowState.Starting: horPosition -= 200 * (1.0f - (float)smoothedProgress); alphaValue = smoothedProgress; bgLayerDepth = 0.5f; bgColor = new Color(new Vector4(1, 1, 1, alphaValue)); break; case WindowState.Ending: horPosition += 200 * (float)smoothedProgress; alphaValue = 1.0f - smoothedProgress; bgLayerDepth = 1; bgColor = Color.White; break; default: alphaValue = 1; bgLayerDepth = 1; bgColor = Color.White; break; } Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue)); spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth); spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0); for (int itemID = 0; itemID < itemList.Count; itemID++) { Vector2 itemPostition = new Vector2(horPosition, verPosition); Color itemColor = Color.White; if (itemID == selectedItem) itemColor = new Color(new Vector4(1,0,0,alphaValue)); else itemColor = new Color(new Vector4(1,1,1,alphaValue)); spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0); verPosition += 30; } } 主程序 你可以在LoadContent方法中创建菜单结构。更新方法必须调用每个菜单的更新方法和MenuInput方法: protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); KeyboardState keybState = Keyboard.GetState(); if (menusRunning) { foreach (MenuWindow currentMenu in menuList) currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds); MenuInput(keybState); } else { } lastKeybState = keybState; base.Update(gameTime); } MenuInput方法将用户输入传递到当前激活的菜单,当输入处理后接受返回的激活菜单: private void MenuInput(KeyboardState currentKeybState) { MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState); if (newActive == startGameEasy) { //set level to easy menusRunning = false; } else if (newActive == startGameNormal) { //set level to normal menusRunning = false; } else if (newActive == startGameHard) { //set level to hard menusRunning = false; } else if (newActive == null) this.Exit(); else if (newActive != activeMenu) newActive.WakeUp(); activeMenu = newActive; } 扩展阅读 虽然对一个基础菜单系统这个方法已经足够了,但对一个完整的游戏来说,MenuWindow类应该是一个抽象类,这样菜单不能作为这个类的实例。作为替代,你应该为菜单和游戏各创建一个新类,这两个类都从MenuWindow类继承。通过这种方式,键盘处理和绘制完全被这个方法处理,无需丑陋的menuRunning变量了。这也是http ://creators . xna . com网站上菜单示例的基础(译者注:这应该是指示例Game State Management)。  

处理2D图像和纹理——显示文字

clock 十一月 23, 2010 16:56 by author alex
  问题 你想绘制一些文字,例如,显示一些操作说明或当前得分。 解决方案 本章前四个教程中学习的SpriteBatch类也可以绘制文字。做法和绘制纹理几乎是一样的,只不过不是导入一个Texture2D,这次导入的是一个SpriteFont,它包含了你想使用的文字大小和类型。然后就可以使用SpriteBatch . DrawString方法绘制文字了。 工作原理 首先需要创建一个SpriteFont文件。右击XNA项目的Content文件夹并选择Add→New item。从对话框中选择SpriteFont,起一个名称(比如ourFont),然后点击Add。 现在看到一个XML页面。最重要的一行是设置FontName属性,可以将这个属性改成你想要绘制的字体。接下去一行可以设置字体大小,你也可以在以后缩放字体大小。 注意:你可以使用任何安装在你的计算机上的TrueType字体。要看到已经安装的字体,可以打开C:\WINDOWS\FONTS目录(例如点击Start按钮,选择Run,并输入这个文件夹地址)。FontName列显示了可以指定的所有字体。例如,如果字体名称为Times New Roman (TrueType),你指定Times New Roman作为FontName 属性。 注意:当在另一台电脑上编译代码时,那台电脑上也必须装有这个字体,否则会报错。 当心:当发布你的程序时,请确保你检查了字体的版权。 创建了SpriteFont文件后,在类中添加一个SpriteFont变量: SpriteFont myFont; 在LoadContent方法中进行初始化: myFont = Content.Load<SpriteFont>("ourFont"); 加载了SpriteFont对象后,就可以在Draw方法中绘制文本了: spriteBatch.Begin(); string myString = "Elapsed seconds: " + gameTime.TotalGameTime.Seconds.ToString(); spriteBatch.DrawString(myFont, myString, new Vector2(50, 20), Color.Tomato); spriteBatch.End(); SpriteBatch.DrawString有一些重载方法接受与SpriteBatch.Draw方法相同的参数,可见教程3-1见到具体解释。 当心:如果你使用的是另一个SpriteBatch.Begin的重载方法,请确保指定 SpriteBlendMode.AlphaBlend作为第一个参数。否则,文字周围的像素会变得不透明,将以你指定的颜色绘制文字。 字符串长度 你可以使用spriteFont查询一个字符串实际占据的像素。这个信息对缩放和截取太长的字符串是很有用的。你可以同时获取字符串的水平和竖直大小: Vector2 stringSize=myFont.MeasureString(myString); 重载方法 SpriteBatch.DrawString有一些重载方法可以可以接受与SpriteBatch.Draw方法相同的参数。解释可见教程3-2。 spriteBatch.DrawString(myFont, secondString, new Vector2(50,100), Color.White,0,new Vector2(0,0),0.5f,SpriteEffet.None,0); StringBuilder SpriteBatch.DrawString还可以使用一个StringBuilder对象代替string。如果你需要对字符串进行大量的修改应该使用StringBuilder对象,例如在一个循环中附加大量的字符串。首先在代码顶部添加一个StringBuilder变量,这样就无需每帧重新创建这个对象了: StringBuilder stringBuilder =new StringBuilder(); 当你想使用这个方法时,首先将长度设为0清空StringBuilder。然后创建字符串,将它StringBuilder传递到SpriteBatch.DrawString方法中: stringBuilder.Length=0; stringBuilder.Append("stringBuilder Example:"); for(int i=0;i<10;i++) stringBuilder.Append(i); spriteBatch.DrawString(myFont, stringBuilder, new Vector2(50,180), Color.White,0,new Vector2(0,0),0.5f,SpriteEffet.None,0); 代码 SpriteFont对象和SpriteBatch在LoadContent方法中进行初始化: protected override void LoadContent() { device = graphics.GraphicsDevice; spriteBatch = new SpriteBatch(GraphicsDevice); myFont = Content.Load<SpriteFont>("ourFont"); } 这两个对象用来在Draw方法中绘制一些文字: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); spriteBatch.Begin(); string myString = "Elapsed seconds: " + gameTime.TotalGameTime.Seconds.ToString(); spriteBatch.DrawString(myFont, myString, new Vector2(50, 20), Color.Tomato); spriteBatch.End(); base.Draw(gameTime); }

处理2D图像和纹理

clock 十一月 23, 2010 16:14 by author alex
问题 当使用SpriteBatch类不当,绘制大量图像时程序会变得很慢。 解决方案 教程3-1已经提到,每帧创建一个新的SpriteBatch类,或绘制每张图像时开启和关闭SpriteBatch类会极大地影响性能。但是还有更多微妙的东西需要考虑到。 工作原理 性能优化:Sprite Sorting Modes SpriteBatch类的Begin方法让你可以设置SpriteSortMode。在讨论到不同模式前,你需要知道显卡喜欢的工作方式是什么,不喜欢的是什么。 在屏幕上绘制一张2D图像是通过绘制两个三角形并将它们填充为从一张纹理采样的颜色实现的。显卡喜欢一次性地绘制大量的三角形不要被打搅。但是当你需要改变纹理时,你就打搅了显卡。换句话说,从同一张纹理绘制100张图像比从100张纹理绘制100张图像快得多。所以,尽可能少地改变当前纹理可以改进性能。 有了这个概念,下面你可以学习可以作为SpriteBatch . Begin方法的一个参数指定的sprite排序模式了: Deferred:这是默认的SpriteSortMode。虽然你几乎从不会用到它,因为它没有排序。 只要通过SpriteBatch . Draw方法将一个sprite添加到batch,这个sprite只是简单地添加到堆栈的顶部。当调用SpriteBatch . End方法时,堆栈中所有的sprite会根据进入堆栈的顺序进行绘制。 Texture:通常基于性能优化和易用性考虑,你会使用这个模式。当调用SpriteBatch . End方法时,在XNA实际要求显卡绘制图像前,堆栈中的所有图像据纹理进行排序。通过这种方式,显卡可以在一个轮次中使用同一张纹理绘制许多三角形,你可以减少干扰显卡改变纹理的次数。但是,当使用alpha透明时这个模式会遇到麻烦,你需要用到下面几个模式中的一个。 BackToFront:当使用alpha混合时,你想让距离最远的物体最先绘制。要知道为什么可见前一个教程。添加图像到SpriteBatch 类的SpriteBatch . Draw方法有一个重载方法可以接受layerDepth为参数。使用这个float参数,你可以指定图像在哪个层,1.0f表示最远的层(例如,包含草地),0.0f表示最近层(例如,在这个层绘制岩石)。你可以指定两者之间的任何值。当使用BackToFront模式时,只要调用SpriteBatch . End方法,在SpriteBatch中的图像就会进行排序让最远层最先被绘制。 FrontToBack:这个模式正好与前一个模式相反,所以最近层的图像最先被绘制。因为屏幕上所有被绘制的像素永远不会被覆盖(因为所有接下来的图像都在这个之后),这会导致最好的性能。但是,使用alpha混合无法工作(见前面的教程)并且只有在所有图像使用相同的纹理的情况下才会获得最好的性能!如果绘制最近层需要交换10次纹理,性能相比使用Texture模式会有更大的下降。 Immediate:相对于其他模式,在这个模式中XNA不会等待调用SpriteBatch . End方法绘制所有图像。只要你调用SpriteBatch. Draw方法将使用相同纹理的图像添加到SpriteBatch,SpriteBatch就会将它们推入堆栈中。当你使用另一个纹理绘制一个sprite时,当前堆栈中的sprite会立即被绘制。 使用前四个模式,只有在调用SpriteBatch . End方法时,sprite才会被排序,渲染状态被设置,三角形和纹理被传送到显卡。这让你可以使用多个SpriteBatch 类对象,以随机顺序添加新sprite,甚至是使用相同渲染状态的3D对象。但在使用Immediate模式时,渲染状态会在调用SpriteBatch. Begin方法时调用,sprite会在调用SpriteBatch . Draw方法之后被绘制。这意味着你可以在两个方法间改变渲染状态!通过这种方式,你可以访问更多的alpha混合模式或使用一个自定义的pixel shader 绘制sprite!但是使用Immediate模式绘制图像时,你无法在绘制其他东西,例如一个3D对象,因为这时你可能会改变渲染状态。当你想用SpriteBatch继续绘制sprite时,渲染状态还没有复位,因此你将使用3D物体的渲染状态和pixel shader 绘制sprite。 性能优化:在一个图像文件中排序多个图像 如前所述,当绘制大量sprite时你想尽可能少地改变当前纹理。SpriteSortingMode. Texture可以帮很多忙,但在一个完整的游戏中你往往需要几百张不同的图像,这会导致显卡在纹理间切换几百次。 一个有用的方法是将互相联系的图像存储在一张大图像中,如图3-4的左图所示。通过这种方式,你可以使用SpriteBatch . Draw 方法的sourceRectangle参数指定绘制大图像中的哪一部分,如教程3-3的代码所示。 如图3-4所示,每张子图像为40 × 40像素。教程3-3的代码定义了一些矩形表示在大图像中的位置。将这些矩形作为SpriteBatch . Draw方法的第三个参数导致在屏幕上只绘制这个子图像。 使用多个SpriteBatche SpriteBatch是一个强大的对象,你可以将整个游戏的图像一次性地添加到一个单独的SpriteBatch中,进行排序并绘制。但是,有些情况中你可能还想使用多个SpriteBatch类。 例如创建一个空战游戏,飞机间互射导弹。当然你有多个种类的飞机和导弹。你可以将飞机和导弹的所有旋转情况存储在一张大纹理中,这可以减少所需纹理的数量。 你还想在飞机引擎的后部有烟雾,并会慢慢消散,在导弹轨迹上也有大量的烟雾。如果使用一个SpriteBatch绘制所有这些图像,会遇到一个问题:你想让所有图像根据纹理排序获得优化的性能。但是,飞机和导弹应该首先绘制,这样才能之后绘制烟雾sprite。而这需要 sprite根据深度排序! 解决方法是使用两个SpriteBatch类对象:planeBatch和smokeBatch。在Draw方法的开头,调用两个SpriteBatch类的Begin方法。然后,对每个飞机和导弹,将飞机和导弹图像添加到planeBatch中,飞机和导弹之后的烟雾图像添加到smokeBatch中。 planeBatch排序模式是Texture,smokeBatch使用BackToFront模式。当所有图像添加到batch之后,首先调用planeBatch的End方法,这个会让所有的飞机和导弹根据纹理排序,会以最优的性能绘制到屏幕。然后。调用smokeBatch的End方法,这会导致烟雾sprite很好地混合在图像上。 这样,你以优化的性能绘制了飞机,而且混合正确。

处理2D图像和纹理——使用层绘制透明图像

clock 十一月 16, 2010 15:09 by author alex
问题 在大多数情况中,你想在多个图像的上面绘制另一张图像。主要问题是,如果所有的图像都是完全覆盖屏幕的,那么它们就会覆盖前面的一张图像。你还要确保首先绘制背景图像。 解决方案 使用XNA,你可以指定层(layer),或者说图像的绘制深度。位于最大深度的图像会被首先绘制。 大多数图像格式支持透明。这意味着除了包含红、绿、蓝的颜色信息,它还包含alpha信息。当开启透明时,图像的透明区域会显示图像下面的东西。 在Xna中,你还可以指定一个颜色变为透明。这对那些没有包含透明信息的图像是很有用的。 工作原理 简单的透明 SpriteBatch类对象可以处理包含透明信息的图像。这在大多数情况中是必须的,例如,当绘制一片草地时,你想在草地上面再绘制一块石头。石头图像是一张简单的白色图像,中间是一块石头。 如果你将这张图像绘制在草地上,那么还可以看到石头图像的白色背景。所以,你需要将图像背景变成透明。通过这种方式,你将石头绘制在草地上,而石头图像的透明部分显示的则是底下草地的颜色。 如果你想让XNA处理图像的透明,需要使用SpriteBatch . Begin () 方法开启alpha混合: spriteBatch.Begin(SpriteBlendMode.AlphaBlend); spriteBatch.Draw(myTexture, Vector2.Zero, Color.White); spriteBatch.End(); 使用颜色键透明 对那些没有透明信息的图像,你可以指定一个颜色作为透明色。这可以通过设置图像属性中的color key实现。在解决方案浏览器中选择图像,让它的属性显示在窗口的右下方。如图3-3所示。找到Color Key Color,将它改变为你想要的颜色。图像中的这个颜色就会当做透明处理。请确保将Color Key Enabled设置为True。 图3-3 设置纹理的Color Key Color属性 使用多个层 如果你想混合多张图像,需要指定哪张图像在最上面。在XNA中,对每张要绘制的图像,你可以以一个介于0到1之间的数字指定层。层1表示这个层首先绘制,层0表示最后绘制,在所有其它层之上。你可以将这个值作为SpriteBatch . Draw方法的最后一个参数。 你需要通过正确设置SpriteBatch.Begin方法的SpriteSortMode开启层排序。 例如,下面的代码首先绘制覆盖整个屏幕的草地。因为这个底层的纹理,你可以安全地将这些图像的层值设置为1。然后绘制一个峭壁,它位于草地之上,所以它们的层值小于1: Rectangle grassRec = new Rectangle(240, 121, 40, 40); Rectangle leftRec = new Rectangle(40, 121, 80, 40); Rectangle topleftRec = new Rectangle(40, 0, 80, 80); Rectangle topRec = new Rectangle(240, 0, 40, 80); Rectangle toprightRec = new Rectangle(320, 0, 80, 80); Rectangle rightRec = new Rectangle(320, 121, 80, 40); Rectangle bottomrightRec = new Rectangle(320, 281, 80, 120); Rectangle bottomRec = new Rectangle(240, 281, 40, 120); Rectangle bottomleftRec = new Rectangle(40, 281, 80, 120); Rectangle centerRec = new Rectangle(240, 201, 80, 40); spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); for (int x = 0; x < 10; x++) for (int y = 0; y < 10; y++) spriteBatch.Draw(myTexture, new Vector2(x * 40, y * 40), grassRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 1); spriteBatch.Draw(myTexture, new Vector2(40, 120), leftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(40, 40), topleftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(120, 40), topRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(160, 40), toprightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(160, 120), rightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(160, 160), bottomrightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(120, 160), bottomRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(40, 160), bottomleftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.Draw(myTexture, new Vector2(120, 120), centerRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f); spriteBatch.End(); 使用两个循环绘制了100个草地块。九张图像构成了峭壁,这九张图像和草地图像都存储在一张图像文件之中,如图3-4左图所示。所以,代码首先声明子图像在图像中所处的矩形。通过将这个矩形作为第三个参数,XNA可以从整张图像中切割出正确的子图像,最终的结果显示在图3-4的右图中。 图3-4 包含多个图像的一张图像(左图)和在一个草地表面上将多张图像混合在一起(右图) 因为草地的层值为1而峭壁图像的层值为0.5,XNA知道应该首先绘制草地,然后才是峭壁。 确保在SpriteBatch . Begin 方法中将SpriteSortMode设置为BackToFront,这样XNA才能知道在绘制它们前需要根据层进行排序: spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); Alpha混合 像素的透明值不是非要“全部或没有”。你也可以设置透明值,例如,设置为70%。例如当一辆车驶过草地时,你可以将灰色的车窗设置为70%透明。通过这种方式,当绘制汽车时,车窗的最终颜色将会是30%灰色,70%草地的额颜色。在教程2-13中可以获取更多知识,这叫做alphablending。 SpriteBlendModes 默认情况下,使用SpriteBatch类进行绘制时alpha混合是开启的。但是,你可以关闭alpha混合(尝试一些看看有什么不同!)或将模式设置为Additive blending。 在后一种情况中,你在像素中绘制的每个颜色都会添加到像素已有的颜色中。所以使用这种模式时,当你在一个红色像素上绘制蓝色像素时,结果是一个紫色像素。如果在这个紫色像素上绘制一个绿色矩形,结果是一个包含白色像素的绿色矩形。这个模式用在诸如火焰或爆炸的效果中,这些效果都是由许多sprites组成的并附加混合在一起,如教程3-12所示。 XNA可以将设置SpriteBatch类的混合模式作为一个SpriteBatch . Begin方法中的参数,你可以指定SpriteBlendMode中的一个: SpriteBlendMode. None关闭混合,忽略任何透明信息将图像绘制到屏幕上,这样会导致覆盖任何屏幕上层深度更大的图像。 SpriteBlendMode.AlphaBlend使用部分已存在的颜色和部分新图像中包含的颜色,对应指定在图像中的alpha值。这对应刚才所举的车窗的例子。 SpriteBlendMode.Additive将新图像的颜色添加到帧缓冲中已经存在的颜色之中。 代码 Draw 方法中的所有代码前面已经写过了。

处理2D图像和纹理——旋转,缩放和镜像一张图像

clock 十一月 16, 2010 14:41 by author alex
问题 在将图像绘制到屏幕前你想对它进行旋转,缩放或镜像操作。 解决方案 SpriteBatch 类(见前一个教程)的Draw方法的不同重载可以让你很简单地进行上述操作。 工作原理 旋转/缩放/镜像一个图像 SpriteBatch.Draw方法有几个有用的重载。下面是最复杂的一个重载方法,参数会在后面加以讨论: public void Draw (Texture2D texture,Vector2 position,Nullable<Rectangle> sourceRectangle,Color color,float rotation,Vector2 origin,Vector2 scale,SpriteEffects effects,float layerDepth) 前两个参数和第四个参数在上一个教程中已经讨论过了,它们可以指定在屏幕上绘制哪个纹理和图像的左上角相对于屏幕的位置。 参数sourceRectangle让你可以只绘制图像的一部分。如果你将多个图像存储在一张图像中,如教程3-3中所做,那么这个参数是很有用的。如果你只想显示整个图像,那么应将这个参数设为null。 旋转 使用rotation 参数你可以旋转一个图像。你需要使用弧度制指定这个旋转角度,所以如果你想让图像顺时针旋转20度,你可以使用MathHelper.ToRadians(20),它可以帮你完成这个转换。 注意:2*PI弧度对应360角度,PI弧度对应180角度。所以如果你想旋转20度,你也可以使用MathHelper.Pi/180.0f*20.0f作为参数。 参数origin可以指定你想让图像上的哪个点位于屏幕上你在第二个参数中指定的位置上。例如,如果你将两个参数都指定为(0,0),那么图像的左上角将位于屏幕的左上角,如图3-2中的左上图所示。   图3-2 不同origin (偏移量)参数对应的情况 如果一张64 × 64大小的图像,你将屏幕位置指定为(0,0),origin指定为(32,32),那么图像的中心点将位于屏幕的左上角,如图3-2的中上图所示。 如果两个参数都是(32,32),那么图像的中心点将位于屏幕的(32,32)位置,如图3-2的右上图所示。这时的结果与左上图一样。 更重要的是,你指定的这个origin参数还将作为旋转的中心点。在左上图的情况中,(0,0)为图像的origin,这个图像会围绕这个点旋转,如图3-2中的左下图所示。如果你指定(32,32)为图像的origin,这个图像会围绕它的中心点旋转,如图3-2的右下图所示。 缩放 如果你想放大/缩小图像,可以设置参数scale。因为这个参数是一个Vector2,你可以对图像的水平方向和竖直方向施加不同的缩放值。例如,设置为(0.5f, 2.0f)将会使宽度变为原始宽度的一半,高度变为原始高度的2倍。 镜像 参数effects让你可以水平或竖直翻转图像。通过flags,你可以使用SpriteEffects.FlipHorizontally|SpriteEffects.FlipVertically同时进行这两个操作,这和将图像旋转180度的效果是相同的。 层深度 最后一个参数让你可以指定图像位于哪个层,当你想将多个图像在各自的顶部时层的概念是很有用的,这会在后面两个教程中详细介绍。 代码 下面的简单代码使用了所有的默认effects: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); spriteBatch.Begin(); spriteBatch.Draw(myTexture, new Vector2(50, 100), null, Color.White, MathHelper.ToRadians(20), new Vector2(32, 32), new Vector2(0.5f, 2.0f), SpriteEffects.FlipVertically, 0); spriteBatch.End(); base.Draw(gameTime); } 这两个教程中涵盖的内容让你可以制作一些基本的2D游戏。你可以到我的网站www.riemers.net中XNA教程中的2D系列,里面有一个完整的2D游戏。

处理2D图像和纹理——使用SpriteBatch类显示2D图像:加载和绘制图像

clock 十一月 16, 2010 11:25 by author alex
问题 你想将一张2D图像绘制到屏幕中以创建一个2D游戏或作为3D游戏的用户界面。你想有一个简单界面可以指定图像显示在屏幕上的位置。 解决方案 XNA Framework已经以SpriteBatch类的形式提供了这个功能,它可以以一个有效率的方式绘制图像。 虽然SpriteBatch的设计以易用性为优先考虑,但它也允许你可以选择一些优化性能的方法,你会在这个教程中看到详细介绍。 注意:因为每个图像是矩形的,所以可以用两个三角形绘制,用一张2D图像作为纹理覆盖其上。SpriteBatch类会自动帮你进行这个操作。因为你实际上绘制的是三角形,使用SpriteBatch类让你可以充分利用硬件加速。 工作原理 在图形编程中,2D图像通常叫做sprite。但是,当一张2D图像用来覆盖在一个表面时,叫做纹理。所以当讨论屏幕上的图像时,我指的是sprite。当讨论绘制sprite要用的颜色时,我指的是纹理。 加载一张纹理 XNA’s Game Studio (GS)支持拖放功能,你可以简单地将纹理拖动到项目中。GS支持很多图像格式。首先,在解决方案浏览器中找到项目的Content文件夹,你可以简单地将一张图像从资源管理器拖动到Content文件夹上。或者也可以通过右击解决方案中的Content文件夹并选择Add→Add Existing Item手动添加,如图3-1所示。 图3-1 在XNA项目中添加一个新图像 然后需要在项目中添加一个变量,给新导入的图像起一个名称,这让你可以从项目中访问这个图像。在类变量中添加以下代码: Texture2D myTexture; 在代码顶部添加以下代码行: namespace BookCode { public class Game1 : Microsoft.Xna.Framework.Game { GraphicsDeviceManager graphics; GraphicsDevice device; SpriteBatch spriteBatch; Texture2D myTexture; public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } 注意:变量device是为GraphicsDevice创建的快捷方式,因为你将频繁地使用它。因为需要在每次程序重新获得焦点后重新连接GraphicsDevice(例如,在最小化操作之后),所以你需要在LoadContent方法中添加device = graphics.GraphicsDevice; 代码行。 然后,将变量连接到图像,这是在LoadContent方法中进行的。如果图像的文件名是image1. Jpg或image1. Png,那么素材(asset)名称为image1。 myTexture = content.Load<Texture2D>("image1"); 内容管道(content pipeline)(可参见教程3-9)从磁盘加载图像文件,处理后将它传递到一个变量中。 将图像放置在屏幕上 在XNA中,将图像放置在屏幕上很简单,使用一个SpriteBatch对象可以实现这个功能。 为了对初学者更加容易使用,一个SpriteBatch对象默认已经在创建新XNA项目时创建了,并且已经在LoadContent方法中实例化了。 当心:创建一个新的SpriteBatch对象需要花费大量的时间和资源,所以不要每帧创建一个新的SpriteBatch对象,应该重用在程序开头创建的SpriteBatch对象。 在Draw方法中,你想让SpriteBatch对象绘制纹理: spriteBatch.Begin(); spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White); spriteBatch.End(); 上面的代码可以在屏幕上绘制图像。第二个参数让你可以指定放置在屏幕上的位置。你可以定义图像的左上角应该被放置到离窗口多少像素的位置。窗口的左上角对应(0,0)点,所以本例中你的图像离开窗口左边缘50个像素,离开窗口上边缘100个像素。在教程的最后你会学到最后一个参数的更多知识。 在屏幕上绘制多个图像 如果你想在屏幕上绘制多个sprite,显然首先需要将纹理导入到项目中并将它们连接到变量。有了对应的名称,一个单独的SpriteBatch对象可以绘制一批sprite。本例中使用两张图像,将它们连接到两个纹理变量。每个纹理被绘制到屏幕的两个不同位置,导致屏幕上绘制了四个sprite: spriteBatch.Begin(); spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White); spriteBatch.Draw(anotherTexture, new Vector2(70, 100), Color.White); spriteBatch.Draw(anotherTexture, new Vector2(70, 200), Color.White); spriteBatch.Draw(myTexture, new Vector2(100, 200), Color.White); spriteBatch.End(); 将图像绘制到一个矩形中 使用spriteBatch.Draw的另一个重载方法,你可以指定屏幕上的目标矩形,这个矩形表示图像绘制到哪儿。如果目标矩形与图像的大小不同,则图像会自动缩放匹配这个目标矩形。 下面的代码定义了一个拥有与窗口同样大小的目标矩形,起点是左上角的(0,0)。结果是,这个图像会扩展到整个屏幕,这对绘制背景是很有用的: spriteBatch.Begin(); PresentationParameters pp = device.PresentationParameters; spriteBatch.Draw(myTexture, new Rectangle(0, 0, pp.BackBufferWidth, pp.BackBufferHeight), Color.White); spriteBatch.End(); 颜色调整 直到现在,你都是用Color. White作为spriteBatch. Draw调用的一个参数。在图像绘制到屏幕之前,它的每个像素的颜色会与这个值相乘。 例如,如果你指定Color. Blue,每个像素的红色和绿色通道会乘以0,蓝色通道会乘以1。这样就会将图像中的红色和绿色分量完全移除。 如果图像只由红色像素构成,所有像素会变成黑色,因为红色通道会乘以0。如果你指定了Color. Purple,红色通道会乘以1,绿色乘以0,蓝色乘以1。所以如果是一张红色图像,它的颜色保持不变。 Color. White参数由红、绿、蓝组成,所以红色,绿色,蓝色通道会乘以1。结果是,你的图像会以初始颜色绘制到屏幕上。 你可以指定一个不为Color. White的值,稍微减弱或增强一个颜色的强度。例如,通过减少红色和绿色通道的颜色强度增加蓝色的强度。 在3D程序中使用SpriteBatch类 无论何时使用SpriteBatch绘制图像,SpriteBatch都会调整显卡的一些设置。如果之后你还要绘制一些3D物体,那么很有可能这些新设置会对3D绘制过程产生无法预料的影响。所以,你想让SpriteBatch保存显卡的初始设置,这样在SpriteBatch完成后可以恢复这些设置: spriteBatch.Begin(SpriteBlendMode.None, SpriteSortMode.Texture, SaveStateMode.SaveState); 代码 下面的简单程序在屏幕上绘制了一张图像。注意myTexture变量的定义和初始化,以及如何在Draw方法中绘制这个图像: using System; using System.Collections.Generic; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Net; using Microsoft.Xna.Framework.Storage; namespace BookCode { public class Game1 : Microsoft.Xna.Framework.Game { GraphicsDeviceManager graphics; GraphicsDevice device; SpriteBatch spriteBatch; Texture2D myTexture; public Game1() { graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; } protected override void Initialize() { base.Initialize(); } protected override void LoadContent() { device = graphics.GraphicsDevice; spriteBatch = new SpriteBatch(GraphicsDevice); myTexture = Content.Load<Texture2D>("smile"); } protected override void UnloadContent() { } protected override void Update(GameTime gameTime) { if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); base.Update(gameTime); } protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); spriteBatch.Begin(); spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White); spriteBatch.End(); base.Draw(gameTime); } } }

在3D世界中创建不同的相机模式——编写自定义内容导入器

clock 十月 23, 2010 10:19 by author alex
2.14 编写自定义内容导入器 问题 你想通过内容管道将一个自定义格式的文件导入到XNA项目中。因为你的文件的扩展名和/或结构与XNA支持的文件格式不同,默认XNA内容管道无法知道如何导入并处理你的文件。 解决方案 你需要编写一个自定义内容导入器,它可以从磁盘中读取文件,将有用的数据格式化为一个对象,做好被内容处理器处理的准备。因此,这个教程假设你知道如何创建自己的内容处理器,可以参见教程3-9了解内容管道的知识。 这个教程只关心自定义导入器。ContentIonmporter需要从磁盘读取数据并将它格式化为一个对象。本教程中,你需要读取存储在一个由逗号分隔值的文件(common-separated values,简称CSV文件)中的两个Vector3并将它们转换为一个矩阵。选择这个例子的理由是因为一个View矩阵需要经过一些计算才能被构建,XNA有一个默认的TypeWriter和TypeReader可以串行化和反串行化Matrix对象,这样本教程就可以只关心从磁盘加载文件,编写内容导入器,从自定义内容处理器获取数据。 看一下图2-27。自定义导入器读取一个CSV文件的数据并创建一个CSVImporterToProcessor对象存储从文件中获取的有用数据。自定义CSVMatrixProcessor处理CSVImporterToProcessor对象中的数据并保存到一个Matrix对象。然后,XNA默认的处理Matrix的TypeWriter和TypeReader会进行串行化和反串行化操作。可参见教程4-15和4-16学习如何编写自己的TypeWriter和TypeReader。 因为导入器和处理器都在编译时调用,你可能想知道将导入器和处理器分开处理的好处。例如,为什么不能更加简单地只定义一个方法读取CSV文件立即将它存储在Matrix对象中,而不是编写两个方法和一个中间CSVMatrixProcessor对象? 将处理过程分开的优点是你可以为多个导入器重用处理器(反之亦然)。例如,XNA框架可以将一个NodeContent对象转换为一个ModelContent对象。如果你从一个不是.x或.fbx格式的文件中导入一个模型,你要做的就是编写一个导入器为你的文件创建一个NodeContent对象,之后就可以使用默认的ModelProcessor了。 图2-27 定义一个内容导入器和它在内容管道中的位置 工作原理 首先使用一个文本编辑器创建一个CSV,下面是我的CSV文件: -5;5;8; 0;2;0; 这些是将在教程中构建的视矩阵的位置的观察目标。 中间CSVImporterToProcessor类 接着,看一下教程3-9中的步骤清单。我调用自己的内容管道项目CSVToViewMatrixPlpeline。在教程3-9的第4步中不进行任何操作;你需要编写一些额外的方法。在本教程的最后,你可以看到内容管道的所有代码。 首先,你需要一个对象用来存储从磁盘中读取的数据。这个对象作为导入器的输出和处理器的输入(这会使用它的内容构建一个视矩阵)。添加一个自定义CSVImporterToProcessor类,它存储两个Vectors: public class CSVImporterToProcessor { private Vector3 position; private Vector3 target; public Vector3 Position ...{ get ...{ return position; } } public Vector3 Target ...{ get ...{ return target; } } public CSVImporterToProcessor(Vector3 position, Vector3 target) { this.position = position; this.target = target; } } 这个类可以存储两个Vectors3。两个getter方法让处理器可以访问这个数据。当调用构造函数时,导入器需要提供所有数据。 ContentImporter ContentImporter用来从文件中读取数据,所以确保在文件的顶部已经添加了包含它们的命名空间(解释请见教程3-9的第3步): using System.IO; 做好了编写自定义导入器的准备,添加以下代码: [ContentImporter(".csv", DefaultProcessor = "CSVMatrixProcessor")] public class CSVImporter : ContentImporter<CSVImporterToProcessor> { public override CSVImporterToProcessor Import(string filename, ContentImporterContext context) { } } 第一行的属性表示这个类可以处理.csv文件,它的输出默认被CSVMatrixProcessor处理(下面就要创建)。在下面的代码中,你指定这个导入器会生成一个CSVImporterToProcessor对象。 在编译时,导入器会导入一个.csv文件并将它转换为一个CSVImporterToProcessor对象。首先打开文件读取第一行,这是由下面代码中的前两行中进行的。要分割;之间的值,你需要使用一个简单的Split方法,这会返回一个三个字符串的数组,每个字符串包含相机位置的数字,接下去的三行代码将这三个字符串转换为浮点数,用来创建最后的位置Vector3。 StreamReader file = new StreamReader(filename); string line = file.ReadLine(); string[] lineData = line.Split(';'); float x = float.Parse(lineData[0]); float y = float.Parse(lineData[1]); float z = float.Parse(lineData[2]); Vector3 position = new Vector3(x,y,z); 从文件中读取第二行,用同样的方法并将它转换为一个观察目标Vector3: line = file.ReadLine(); lineData = line.Split(';'); x = float.Parse(lineData[0]); y = float.Parse(lineData[1]); z = float.Parse(lineData[2]); Vector3 target = new Vector3(x,y,z); 现在,有了数据就可以创建一个CSVImporterToProcessor对象了: CSVImporterToProcessor finalData = new CSVImporterToProcessor(position,target); return finalData; 这个对象被发送到用户选择的处理器。显然,处理器应该可以将CSVImporterToProcessor对象作为输入。 在内容处理器中接受数据 因为CSVImporterToProcessor类是自定义类,你需要创建一个自定义处理器。这个处理器会将CSVImporterToProcessor对象转换为一个Matrix对象。看一下本章第一个教程了解如何根据相机的位置和观察目标构建一个视矩阵: [ContentProcessor] public class CSVMatrixProcessor : ContentProcessor<CSVImporterToProcessor, Matrix> { public override Matrix Process(CSVImporterToProcessor input, ContentProcessorContext context) { Vector3 up = new Vector3(0, 1, 0); Vector3 forward = input.Target - input.Position; Vector3 right = Vector3.Cross(forward, up); up = Vector3.Cross(right, forward); Matrix viewMatrix = Matrix.CreateLookAt(input.Position, input.Target, up); return viewMatrix; } } 你声明这个处理器可以将一个CSVImporterToProcessor对象转换为一个Matrix对象。CSVImporterToProcessor对象中的position和target用来创建视矩阵。XNA知道如何从二进制文件串行化/反串行化一个Matrix对象,并将Matrix对象加载到XNA项目中,所以你无需编写自定义的TypeWriter和TypeReader。 使用方法 当你确认已经完成了教程3-9中的9个步骤后,你就可以将.csv文件导入到项目中去了。当在解决方案资源管理器中选择一个.csv文件时,你应该可以在属性窗口中注意到这个文件由CSVImporter导入,如图2-28所示,因为你将CSVImporter声明为默认导入器,CSVMatrixProcessor作为处理器。 图2-28 选择内容导入器和处理器 导入.csv文件后,你要做的就是使用下面的代码从.csv文件中加载一个视矩阵: protected override void LoadContent() { viewMatrix = Content.Load<Matrix>("camerasettings"); } 代码 因为内容管道很短,我把它放在了一起。第一个代码块是命名空间。命名空间中的第一个类是CSVImporterToProcessor类,它可以存储所有数据。接下来是ContentImporter类,它可以从一个.csv文件中读取并存储CSVImporterToProcessor对象中的数据集。最后的代码块是内容处理器,可以基于CSVImporterToProcessor对象中的内容构建一个视矩阵。因为XNA自带可以处理Matrix对象的TypeWriter和TypeReader,所以上面的代码就是一个完整功能的内容管道了。如果你的处理器创建了一个自定义类对象,可参见教程4-15和4-16学习如何创建自己的TypeWriter和TypeReader。 using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Graphics; using Microsoft.Xna.Framework.Content.Pipeline.Processors; using System.IO; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler; namespace CVSToViewMatrixPipeline { public class CSVImporterToProcessor { private Vector3 position; private Vector3 target; public Vector3 Position ...{ get ...{ return position; } } public Vector3 Target ...{ get ...{ return target; } } public CSVImporterToProcessor(Vector3 position, Vector3 target) { this.position = position; this.target = target; } } [ContentImporter(".csv", DefaultProcessor = "CSVMatrixProcessor")] public class CSVImporter : ContentImporter<CSVImporterToProcessor> { public override CSVImporterToProcessor Import(string filename, ContentImporterContext context) { StreamReader file = new StreamReader(filename); string line = file.ReadLine(); string[] lineData = line.Split(';'); float x = float.Parse(lineData[0]); float y = float.Parse(lineData[1]); float z = float.Parse(lineData[2]); Vector3 position = new Vector3(x,y,z); line = file.ReadLine(); lineData = line.Split(';'); x = float.Parse(lineData[0]); y = float.Parse(lineData[1]); z = float.Parse(lineData[2]); Vector3 target = new Vector3(x,y,z); CSVImporterToProcessor finalData = new CSVImporterToProcessor(position, target); return finalData; } } [ContentProcessor] public class CSVMatrixProcessor : ContentProcessor<CSVImporterToProcessor, Matrix> { public override Matrix Process(CSVImporterToProcessor input, ContentProcessorContext context) { Vector3 up = new Vector3(0, 1, 0); Vector3 forward = input.Target - input.Position; Vector3 right = Vector3.Cross(forward, up); up = Vector3.Cross(right, forward); Matrix viewMatrix = Matrix.CreateLookAt(input.Position, input.Target, up); return viewMatrix; } } }

在3D世界中创建不同的相机模式——创建一个模糊(Blur),(发光)Glow Post-Processing Effect

clock 十月 23, 2010 10:14 by author alex
2.13 创建一个模糊(Blur),(发光)Glow Post-Processing Effect 问题 你想在最终图像上添加模糊效果,或给场景中的物体添加光泽。 解决方案 这两个effect都是post-processing effect。可参见前面的教程学习如何建立一个post-processing框架。 你可以通过计算每个像素和一些周围像素的颜色值平均值并用这些值替换像素的原始颜色实现模糊效果。例如,如果图2-25中的格子是一张需要模糊的图像,你该如何计算像素9的最终颜色呢? 图2-25 计算2D纹理中的像素颜色的平均值 你可以通过将9个数字相加并除以9得到这9个数字的平均数,计算颜色平均值的方法是一样的:将2-4像素、8 到10像素、14到16像素的颜色相加并除以像素的数量:这里是9。 如果除了像素9之外其他像素的颜色都是白色的,如图2-16左图的情况,这会导致像素9的颜色扩展到像素2到4,8到10和14到16,使像素9“扩散”了。但是,像素9本身的颜色变暗了,因为它也被周围颜色的平均值替换了,如图2-26中图所示。要获取一个发光效果,你要混合原始图像,在这个图像的某处存储了像素9的原始颜色。所以,最后像素9拥有自己的原始颜色,而像素9周围的像素拥有一点像素9的颜色,就好像像素9有了一个发光效果,如图2-26的右图所示。 图2-26 创建一个发光(glow)效果 工作原理 下面是一些说明。要获取一个漂亮的模糊效果,你需要对中心像素周围的大量像素取平均值。更好的方法是首先通过对中心像素同一行中的一些像素取平均值进行水平方向的模糊,然后,通过对中心像素同一列的一些像素取平均值对刚才得到的模糊进行竖直方向的模糊。这会获得两个1D的平均值而不是2D,而2D的平均值要处理多得多的像素才能获得漂亮的结果。 第二,你需要判断对哪些像素进行平均。要获得最好的结果,你需要给靠近中心像素的像素更多的“关注”,而远处的像素“关注”较少,这叫做施加更多的“权重(weight)”。为了你的方便,我已经计算了对应高斯模糊的一些偏移量和权重,高斯模糊对应通常情况下的模糊。 看一下下面的数组,它包含距离中心像素的距离信息。第一个数据为0偏移量,表示中心像素本身。第二个数据为0.005偏移量,对应距离中心像素非常近的采样像素。要保持对称,你需要采样距离中心像素左右两侧0.005偏移量的两个像素,你给这两个像素颜色都施加了一个0.102大小的权重,这可以在第二个数组中看到。然后,你采样距离中心像素左右距离为0.0117的两个像素,施加一个较小的0.0936的权重。以此类推直至距离中心像素较远的像素 ,而且权重依次减少。 float positions[] = { 0.0f, 0.005, 0.01166667, 0.01833333, 0.025, 0.03166667, 0.03833333, 0.045, }; float weights[] = { 0.0530577, 0.1028506, 0.09364651, 0.0801001, 0.06436224, 0.04858317, 0.03445063, 0.02294906, }; 如果你将所有点的权重相加,结果为1,这样才不会让图像中的颜色损失或增加。 注意:相对于其他像素,中央像素的权重看起来较小,但是,因为+0和-0是相同的,中央像素会被处理两次,使它的权重加倍,导致在最终结果中它的影响最大。 你还要用到一个xBlurSize变量,可以从XNA程序中调整模糊效果的宽度: float xBlurSize = 0.5f; 你已经基于上一个教程创建了一个空的HorBlur effect: // PP Technique: HorBlur PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; return Output; } technique HorBlur { pass Pass0 { VertexShader = compile vs_1_1 DefaultVertexShader(); PixelShader = compile ps_2_0 HorBlurPS(); } } 实际的effect定义在pixel shader中。对每个前面的数组中定义的周围像素,你需要采样颜色并乘以它的权重。基于对称,操作是从中央像素两侧进行的。最后,将所有颜色相加。 从中央像素的TexCoord加上来自于positions数组中的值(这个值作为水平纹理坐标)可以获取周围像素的位置: PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; for (int i = 0; i < 8; i++) { float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(positions[i], 0)*xBlurSize); samplePos *= weights[i]; float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord -float2(positions[i], 0)*xBlurSize); sampleNeg *= weights[i]; Output.Color += samplePos + sampleNeg; } return Output; } 你可以看到xBlurSize变量用来增加/减少中心像素和周围像素的距离。 现在在XNA程序中,你只需简单地给这个变量赋一个值并开启这个effect: List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("HorBlur"); postProcessor.Parameters["xBlurSize"].SetValue(0.5f); postProcessor.PostProcess(ppEffectsList); 垂直模糊 有了水平模糊,垂直模糊也很容易创建。现在不是将positions数组中的值添加到水平纹理坐标,而是添加到竖直坐标,选择同一列的像素作为处理的像素: // PP Technique: VerBlur PPPixelToFrame VerBlurPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; for (int i = 0; i < 8; i++) { float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(0, positions[i])*xBlurSize); samplePos *= weights[i]; float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(0, positions[i])*xBlurSize); sampleNeg *= weights[i]; Output.Color += samplePos + sampleNeg; } return Output; } technique VerBlur { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 VerBlurPS(); } } 组合水平和垂直模糊你可以获得一个漂亮的模糊图像: List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("HorBlur"); ppEffectsList.Add("VerBlur"); postProcessor.Parameters["xBlurSize"].SetValue(0.5f); postProcessor.PostProcess(ppEffectsList); 发光效果 如教程介绍中解释的那样,你可以通过将模糊过的图像与原始图像混合获取发光效果,通过这个方法,原始的轮廓会锐化。 这需要后备缓冲中存在模糊过的图像,这样在第二个pass中,你可以与原始图像进行混合。所以,你需要定义一个新technique,这个technique将垂直模糊作为第一个pass, blend-in作为第二个pass: technique VerBlurAndGlow { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 VerBlurPS(); } pass Pass1 { AlphaBlendEnable = true; SrcBlend = SrcAlpha; DestBlend = InvSrcAlpha; PixelShader = compile ps_2_0 BlendInPS(); } } 第二个pass是新的。你可以看到在pass开始时改变了三个渲染状态:开启alpha混合和设置alpha函数。马上你就会学到这个alpha函数。Effect由第一个pass开始,这样模糊图像会保存在后备缓冲中。然后,开始第二个pass,让你将原始图像混合到后备缓冲中的图像。 下面是第二个pass的pixel shader: // PP Technique: VerBlurAndGlow PPPixelToFrame BlendInPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; float4 finalColor = tex2D(originalSampler, PSIn.TexCoord); finalColor.a = 0.3f; Output.Color = finalColor; return Output; } 这里,你采样原始图像中像素的颜色并调整它的alpha(透明度)值。当在XNA进行混合时,使用如下规则: finalColor=sourceBlend*sourceColor+destBlend*destColor 在technique定义中,你将sourceBlend设置为SourceAlpha。这意味着sourceBlend等于你要混合的颜色的alpha值,这里是0.3f,是你在前面的代码中定义的。而且,你将destBlend设置为InvSourceAlpha,意思是1–SourceAlpha,所以destBlend为1–0.3f = 0.7f。 总的来说,最终颜色的70%会取自后备缓冲中的颜色(即第一个pass存储的模糊图像),剩下的30%取自你想写入到后备缓冲中的新颜色,本例中是原始图像。 你还没有定义originalSampler,在effect文件的顶部添加它: texture originalImage; sampler originalSampler = sampler_state { texture = <originalImage>; magfilter = LINEAR; minfilter = LINEAR; mipfilter = LINEAR; }; 在XNA项目中,你需要将后备缓冲中获取的原始图像存储到originalImage变量中。幸运的是,使用前一个教程的框架,在第一个effect中很容易做到: if (currentTechnique == 0) { device.ResolveBackBuffer(resolveTexture, 0); textureRenderedTo = resolveTexture; ppEffect.Parameters["originalImage"].SetValue(textureRenderedTo); } 最后一行代码将原始图像发送到HLSL effect中。 现在所有effect准备就绪,代码会首先对图像进行垂直模糊,之后,通过VerBlurAndGlow effect的第一个pass对结果进行水平模糊,在第一个pass之后,模糊过的图像保存在帧缓冲中,让你可以混合到原始图像中! List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("HorBlur"); ppEffectsList.Add("VerBlurAndGlow"); postProcessor.Parameters["xBlurSize"].SetValue(0.5f); postProcessor.PostProcess(ppEffectsList); 代码 为了能够处理所有的post-processing effects,你需要将纹理采样器stage绑定到包含场景的图像上。发光效果还需要在originalImage变量中存储原始图像。 float xTime; float xBlurSize = 0.5f; texture textureToSampleFrom; sampler textureSampler = sampler_state { texture = <textureToSampleFrom>; magfilter = LINEAR; minfilter = LINEAR; mipfilter = LINEAR; } texture originalImage; sampler originalSampler = sampler_state { texture = <originalImage>; magfilter = LINEAR; minfilter = LINEAR; mipfilter = LINEAR; }; float positions[] = { 0.0f, 0.005, 0.01166667, 0.01833333, 0.025, 0.03166667, 0.03833333, 0.045, }; float weights[] = { 0.0530577, 0.1028506, 0.09364651, 0.0801001, 0.06436224, 0.04858317, 0.03445063, 0.02294906, }; struct PPVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct PPPixelToFrame { float4 Color : COLOR0; }; PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { PPVertexToPixel Output = (PPVertexToPixel)0; Output.Position = inPos; Output.TexCoord = inTexCoord; return Output; } // PP Technique: HorBlur PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; for (int i = 0; i < 8; i++) { float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(positions[i], 0)*xBlurSize); samplePos *= weights[i]; float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(positions[i], 0)*xBlurSize); sampleNeg *= weights[i]; Output.Color += samplePos + sampleNeg; } return Output; } technique HorBlur { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 HorBlurPS(); } } // PP Technique: VerBlur PPPixelToFrame VerBlurPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; for (int i = 0; i < 8; i++) { float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(0, positions[i])*xBlurSize); samplePos *= weights[i]; float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(0, positions[i])*xBlurSize); sampleNeg *= weights[i]; Output.Color += samplePos + sampleNeg; } return Output; } technique VerBlur { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 VerBlurPS(); } // PP Technique: VerBlurAndGlow PPPixelToFrame BlendInPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; float4 finalColor = tex2D(originalSampler, PSIn.TexCoord); finalColor.a = 0.3f; Output.Color = finalColor; return Output; } technique VerBlurAndGlow { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 VerBlurPS(); } pass Pass1 { AlphaBlendEnable = true; SrcBlend = SrcAlpha; DestBlend = InvSrcAlpha; PixelShader = compile ps_2_0 BlendInPS(); } } 扩展阅读 每一个完工的游戏都包含一组post-processing effects,因为它们可以在最终图像上施加额外的处理。模糊效果相对来说是简单的,一些模糊或发光常用来掩盖3D物体边缘的锯齿。 post-processing effects牵涉的东西很多,但通过使用多个pass和alpha混合的发光效果,你可以知道基本的原理。

在3D世界中创建不同的相机模式——创建一个Post-Processing Framework

clock 十月 23, 2010 10:04 by author alex
2.12 创建一个Post-Processing Framework 问题 你想在最终的图像上添加一个2D post-processing effect,诸如模糊,扭曲,摇晃,变焦,边缘检测等。 解决方案 首先将2D或3D场景绘制到屏幕,在Draw过程的最后,在后备缓冲的内容还没有发送到屏幕前,你需要将这个后备缓冲的内容存储在一张2D图像中,解释请见教程3-8。 然后,将这张2D图像绘制到屏幕上,但通过一个自定义的pixel shader实现,这也是本教程中有趣的部分。在pixel shader中, 你可以单独处理图像中的每个像素。你可以通过使用一个简单的SpriteBatch实现以上操作(见教程3-1),但是SpriteBatch不支持多个pass的alpha混合(如下一个教程介绍的effect)。要解决这个问题并创建一个支持所有post-processing effect的框架,你需要手动定义覆盖整个屏幕的三角形,在这个三角形上施加最终的图像。通过这种方式,你可以使用任意的pixel shader处理最终图像的像素。 如果你想组合多个post-processing effect,你可以在每个effect之后将结果图像绘制到一个RenderTarget2D变量中而不是绘制到后备缓冲中。这样最后一个effect的最终结果才会被绘制到后备缓冲中。 工作原理 首先需要在程序中添加一些变量,这些变量包括ResolveTexture2D,它用来获取后备缓冲的内容(可见教程3-8),RenderTarget2D,它用来对多个effects进行排列。你还需要一个effect文件保存post-processing technique(s)。 VertexPositionTexture[] ppVertices; RenderTarget2D targetRenderedTo; ResolveTexture2D resolveTexture; Effect postProcessingEffect; float time = 0; 因为你要定义两个三角形覆盖整个屏幕,所以需要定义顶点: private void InitPostProcessingVertices() { ppVertices = new VertexPositionTexture[4]; int i = 0; ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, 1, 0f), new Vector2(0, 0)); ppVertices[i++] = new VertexPositionTexture(new Vector3(1, 1, 0f), new Vector2(1, 0)); ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, -1, 0f), new Vector2(0, 1)); ppVertices[i++] = new VertexPositionTexture(new Vector3(1, -1, 0f), new Vector2(1, 1)); } 这个方法定义了矩形的四个顶点,用来绘制TriangleStrip (可参见教程5-1)形式的两个三角形。请记住屏幕坐标的范围是[-1,1],而纹理坐标的范围是[0,1]。 你可以看到刚才定义的位置就是屏幕坐标,点(-1,-1)对应窗口的左上角,点(1,1)对应右下角。你也可以指定纹理坐标的左上角(0,0)位于窗口的左上角(-1,-1),纹理坐标的右下角(1,1)位于窗口右下角。如果将这个矩形绘制到这个屏幕中,图像就会覆盖整个窗口。 注意:因为窗口是2D的,在顶点的位置中可以无需第三个坐标。但是,对屏幕的每个像素,XNA会将距离相机的位置保存到深度缓冲中,这实际上就是第三个坐标。通过将这个距离指定为0,表示将图像绘制到尽可能离相机近的地方(更确切的说,将图像绘制在近裁平面上)。 别忘了在Initialize方法中调用这个方法: InitPostProcessingVertices(); 最后三个变量应在LoadContent方法中进行初始化: PresentationParameters pp = GraphicsDevice.PresentationParameters; targetRenderedTo = new RenderTarget2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format); resolveTexture = new ResolveTexture2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format); postProcessingEffect = content.Load<Effect>("content/postprocessing"); 可参见教程3-8学习更多有关渲染目标的知识。这种情况中重要的是新的渲染目标和窗口的属性一样,它需要有相同的宽度,高度,颜色格式,这些都可以从图形设备的PresentationParameters结构中获取。通过这种方式,你可以很简单地获取纹理,然后对它进行post-process,将结果发送到屏幕,而无需做任何缩放和颜色映射的操作。因为你使用的是完全尺寸的纹理,无需任何mipmaps (可参见教程3-7的注释)。这意味着你只需一个mipmap level,就是纹理的原始大小。你还要加载包含post-processing technique(s)的effect文件。 加载了变量后,就可以开始下面的工作了。在与以往一样绘制了场景后,你想调用一个方法可以获取后备缓冲中的内容,然后对它进行处理,将结果发送到后备缓冲。这就是PostProcess方法要进行的操作: private void PostProcess() { device.ResolveBackBuffer(resolveTexture, 0); Texture2D textureRenderedTo = resolveTexture; } 第一行代码将后备缓冲中的当前内容转换到一个ResolveTexture2D,即本例中的resolveTexture变量。这个变量包含了要绘制到屏幕中的场景。你将这个变量存储为一个普通的Texture2D,叫做textureRenderedTo。 然后,使用post-processing effect将这个textureRenderedTo绘制到覆盖整个窗口的矩形中。在这个简单教程中,你将定义一个叫做Invent的effect,它可以将图像中的每个像素的颜色反相。 postProcessingEffect.CurrentTechnique = postProcessingEffect.Techniques["Invert"]; postProcessingEffect.Begin(); postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo); foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2); pass.End(); } postProcessingEffect.End(); 你首先选择了用来将最终图像绘制到屏幕的post-processing technique,开始这个effect。 然后,将textureRenderedTo传递到显卡上,这样effect可以从中进行采样。最后,对post-processing technique的每个pass,你让显卡绘制覆盖整个屏幕的两个三角形。你需要编写effect的代码让两个三角形显示这个图像,以你选择的方式进行处理。 注意:当在3D世界中绘制物体时,你总要设置World, View和Projection矩阵。这些矩阵让显卡中的vertex shader将3D坐标映射到屏幕的对应像素上。但在这个例子中,你已经在屏幕空间中定义了两个三角形的位置,所以无需设置这些矩阵,因为现在vertex shader不会改变顶点的位置,只是简单地将它们传递到pixel shader中。 别忘了在调用Draw方法中调用这个方法: PostProcess(); HLSL 只剩最后一步了:在HLSL中定义post-processing technique。不要担心,因为这里使用的HLSL非常简单。所以,打开一个新文件,命名为postprocessing. fx。 texture textureToSampleFrom; sampler textureSampler = sampler_state { texture = <textureToSampleFrom>; magfilter = POINT; minfilter = POINT; mipfilter = POINT; }; struct PPVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct PPPixelToFrame { float4 Color : COLOR0; }; PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0,float2 inTexCoord: TEXCOORD0) { PPVertexToPixel Output = (PPVertexToPixel)0; Output.Position = inPos; Output.TexCoord = inTexCoord; return Output; } // PP Technique: Invert PPPixelToFrame InvertPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; float4 colorFromTexture = tex2D(textureSampler, PSIn.TexCoord); Output.Color = 1-colorFromTexture; return Output; } technique Invert { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_1_1 InvertPS(); } } 这个代码还可以再短一点,但我想和前面教程中的HLSL代码的结构保持一致。在technique 定义的底部,表示的是technique的名称和使用的vertex shader和pixel shader。在它之上是vertex shader和pixel shader,在代码顶部是可以从XNA程序中设置的变量。对这个简单例子,你只需设置从后备缓冲获取的2D图像。 然后,在显卡中创建一个纹理采样器,这也是后面的pixel shader从中进行颜色采样的变量。你将这个采样器连接到刚才定义的纹理,然后声明如果代码要求的一个坐标的颜色不是100%对应一个像素时应该进行的操作,这里你指定采样器提取最近像素的颜色。 注意:纹理坐标是一个float2,X和Y值在0和1之间。因为这些数字是floats,对它们的任何运算几乎都会导致一个四舍五入的误差。这意味着当你使用这样一个坐标从纹理采样时,大多数纹理坐标不会精确地对应纹理上的一个像素,但会非常接近。这就是为什么你需要指定纹理采样器应该怎样做的原因。 然后,你定义了两个结构:一个保存从vertex shader发送到pixel shader的信息。这个信息只包含屏幕坐标和纹理到哪采样获取像素的颜色。第二个结构保存pixel shader输出。对每个像素,pixel shader只需要计算颜色。 vertex shader让你处理发送到显卡的每个顶点的数据。3D程序中vertex shader最重要的任务之一就是将3D坐标转换为2D屏幕坐标。在post-processing effects的情况中,vertex shader并不真正有用,因为你已经定义了两个三角形的顶点的屏幕坐标!所以,你只需让vertex shader将输入的位置传递到输出就可以了。 然后,在pixel shader中才是post-processing effect的处理。对绘制到屏幕的每个像素,调用这个方法,让你可以改变像素的颜色。在pixel shader中,首先创建一个空的叫做Outputde 输出结构, 然后,这个颜色从textureSampler进行采样。如果pixel shader只是简单地输出这个颜色,那么输出的图像与原始图像是一样的,因为窗口每个像素都是从原始图像的原始位置采样它的颜色的。所以你想改变采样的坐标或从原始图像获取的颜色,这会在下一段中进行这个操作。 colorFromTexture变量包含四个介于0和1之间的值(红,绿,蓝和alpha)。本例中,通过从1减去这些值将它们反相。将这个反相过的颜色保存到Output结构中并返回。 当运行代码时,场景会被保存到textureRenderedTo纹理中,每个像素的颜色会在绘制到屏幕前被反相。 多个Post-Processing Effects队列 再加一些代码让你可以处理多个post-processing effects队列。在Draw方法中,你将创建一个集合包含要施加的post-processing techniques,然后将这个集合传递到PostProcess方法中: List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("Invert"); ppEffectsList.Add("Invert"); PostProcess(ppEffectsList); 现在你只定义了一个Invert technique,所以这个简单例子中你使用了这个technique 两次。通过反相一个反相过的图像,结果是再次获得了原始图像,这有什么令人激动的? 你要调整PostProcess方法让它接受effect集合作为参数。如你所见,这个方法的开始部分被扩展为可以处理多个 Post-Processing Effects: public void PostProcess(List<string> ppEffectsList) { for (int currentTechnique = 0; currentTechnique < ppEffectsList.Count; currentTechnique++) { device.SetRenderTarget(0, null); Texture2D textureRenderedTo; if (currentTechnique == 0) { device.ResolveBackBuffer(resolveTexture, 0); textureRenderedTo = resolveTexture; } else { textureRenderedTo = targetRenderedTo.GetTexture(); } if (currentTechnique == ppEffectsList.Count - 1) device.SetRenderTarget(0, null); else device.SetRenderTarget(0, targetRenderedTo); postProcessingEffect.CurrentTechnique= postProcessingEffect.Techniques[ppEffectsList[currentTechnique]]; postProcessingEffect.Begin(); postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo); foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2); pass.End(); postProcessingEffect.End(); } } } 这个方法的思路如图2-14所示。对集合中的每个effect,你将渲染目标中的内容保存到一张纹理中,然后使用当前的effect再次将它绘制到渲染目标中。这个规则有两个例外。 首先,对第一个effect,获取后备缓冲中的内容,而不是RenderTarget的内容。最后,对最后一个effect,将结果绘制到后备缓冲,这样它会被绘制到屏幕。这个过程如图2-14所示。 图2-14 多个post-processing effects队列 前面的代码显示了工作流程。如果是第一个technique,则将后备缓冲中的内容存储到textureRenderedTo,否则,将渲染目标的内容存储到textureRenderedTo。无论哪种方式, textureRenderTo都会包含最终要绘制的内容。如教程3-8的解释,在调用RenderTarget 的GetTexture前,你必须激活另一个渲染目标,这是由这个方法的第一行代码实现的。 然后检查当前technique是否是集合中的最后一个,如果是,通过在device. SetRenderTarget方法中传递null(你也可以不使用这行代码,因为在方法顶部已经做了这个操作)将后备缓冲设置为当前渲染目标。否则,将自定义的渲染目标作为当前渲染目标。 代码的其他部分保持不变。 作为post-processing technique的第二个简单例子,你可以根据时间改变颜色值。将这个代码添加到. fx文件的顶部: float xTime; 这个变量可以在XNA程序中设置,在HLSL代码中读取。将这行代码添加到. fx文件的最后: // PP Technique: TimeChange PPPixelToFrame TimeChangePS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord); Output.Color.b *= sin(xTime); Output.Color.rg *= cos(xTime); Output.Color += 0.2f; return Output; } technique TimeChange { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 TimeChangePS(); } } 对图像的每个像素,蓝色通道会乘以由xTime变量决定的正弦值,红色和绿色乘以余弦值。记住,正弦和余弦产生一个介于–1和+1之间的波形,颜色通道的负值会被截取到0。 使用这个technique绘制最终图像: List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("Invert"); ppEffectsList.Add("TimeChange"); postProcessingEffect.Parameters["xTime"].SetValue(time); PostProcess(ppEffectsList); 注意你将xTime变量设置为time,需要在XNA代码中指定这个time变量: float time; 在Update方法中更新变量: time += gameTime.ElapsedGameTime.Milliseconds / 1000.0f; 当运行代码时,你会看到图像的颜色会随时间发生变化。还不是很漂亮,但是你可以只基于它们的原始颜色改变像素的颜色。在下一个教程中,还要考虑像素周围的颜色决定最终颜色。 代码 下面的代码定义顶点,这些顶点构成矩形用来显示最终图像: private void InitPostProcessingVertices() { ppVertices = new VertexPositionTexture[4]; int i = 0; ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, 1, 0f), new Vector2(0, 0)); ppVertices[i++] = new VertexPositionTexture(new Vector3(1, 1, 0f), new Vector2(1, 0)); ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, -1, 0f), new Vector2(0, 1)); ppVertices[i++] = new VertexPositionTexture(new Vector3(1, -1, 0f), new Vector2(1, 1)); } 在Draw方法中,你想往常一样绘制场景。在绘制之后,定义使用哪个post-processing effects,并将集合传递到PostProcess方法中: protected override void Draw(GameTime gameTime) { device.Clear(ClearOptions.Target|ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0); //draw model Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(0, 0, 0); myModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in myModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = fpsCam.ViewMatrix; effect.Projection = fpsCam.ProjectionMatrix; } mesh.Draw(); } //draw coordcross cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); List<string> ppEffectsList = new List<string>(); ppEffectsList.Add("Invert"); ppEffectsList.Add("TimeChange"); postProcessingEffect.Parameters["xTime"].SetValue(time); PostProcess(ppEffectsList); base.Draw(gameTime); } 在Draw方法的最后,调用PostProcess方法,这个方法获取后备缓冲, 使用一个或多个post-processing effects 将图像绘制到屏幕中: public void PostProcess(List<string> ppEffectsList) { for (int currentTechnique = 0; currentTechnique < ppEffectsList.Count; currentTechnique++) { device.SetRenderTarget(0, null); Texture2D textureRenderedTo; if (currentTechnique == 0) { device.ResolveBackBuffer(resolveTexture, 0); textureRenderedTo = resolveTexture; } else { textureRenderedTo = targetRenderedTo.GetTexture(); } if (currentTechnique == ppEffectsList.Count - 1) device.SetRenderTarget(0, null); else device.SetRenderTarget(0, targetRenderedTo); postProcessingEffect.CurrentTechnique = postProcessingEffect.Techniques[ppEffectsList[currentTechnique]]; postProcessingEffect.Begin(); postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo); foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements); device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2); pass.End(); } postProcessingEffect.End(); } } 在HLSL文件中,确保将纹理采样器连接到textureToSampleFrom变量上: float xTime; texture textureToSampleFrom; sampler textureSampler = sampler_state { texture = <textureToSampleFrom>; magfilter = POINT; minfilter = POINT; mipfilter = POINT; } struct PPVertexToPixel { float4 Position : POSITION; float2 TexCoord : TEXCOORD0; }; struct PPPixelToFrame { float4 Color : COLOR0; }; PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0) { PPVertexToPixel Output = (PPVertexToPixel)0; Output.Position = inPos; Output.TexCoord = inTexCoord; return Output; } // PP Technique: Invert PPPixelToFrame InvertPS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; float4 colorFromTexture = tex2D(textureSampler, PSIn.TexCoord); Output.Color = 1-colorFromTexture; return Output; } technique Invert { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_1_1 InvertPS(); } } // PP Technique: TimeChange PPPixelToFrame TimeChangePS(PPVertexToPixel PSIn) : COLOR0 { PPPixelToFrame Output = (PPPixelToFrame)0; Output.Color = tex2D(textureSampler, PSIn.TexCoord); Output.Color.b *= sin(xTime); Output.Color.rg *= cos(xTime); Output.Color += 0.2f; return Output; } technique TimeChange { pass Pass0 { VertexShader = compile vs_1_1 PassThroughVertexShader(); PixelShader = compile ps_2_0 TimeChangePS(); } }

在3D世界中创建不同的相机模式——创建一个ROAM地形

clock 十月 23, 2010 09:56 by author alex
2.11 创建一个ROAM地形 问题 虽然你可以使用前一个教程的四叉树技术让XNA只绘制在相机视野中的地形小块,这个方法还不是最好的。那些远离相机的方块仍会以离相机很近的方块的同样细节进行绘制,而有些三角形在屏幕上的绘制大小不超过一个像素。 因此,你想使用Lecvel-Of-Detail(LOD)方法让远离相机的方块以低细节绘制,当使用不同的细节层次绘制地形时,你会发现在边界上会出现破裂。 译者注:ROAM是Real-Time Camera-Dependant Optimally Adapting Mesh的缩写,可翻译为依赖相机的实时优化网格。 解决方案 本教程介绍一个强大的技术,这个技术从覆盖整个屏幕的两个三角形开始。在算法的迭代中,每个三角形计算离开相机的距离并判断它是否需要分割成两个三角形,最后,你获得一个最匹配当前相机位置的网格。 只要相机的位置或旋转发生改变就需要更新这个地形网格,要对相机的新状态保持优化,每个三角形还需要判断是否需要和邻近的三角形合并以再次减少细节层次。 工作原理 本教程的目标是从覆盖整个屏幕的两个三角形开始,创建一个算法让每个三角形可以根据相机判断是否需要分割或合并。 在实际开始之前,你需要找到一个三角形布局可以很容易地进行分割和合并。针对处理地形的情况,我使用一个基于方块的固定网格。如果你将这个由两个三角形组成的方块分割以增加细节时,每个方块需要被分割成由8个三角形组成的4个方块,如图2-14的右图所示。添加额外的六个三角形是前进过程中的一大步。 图2-14 在基于方块的布局中增加细节层次 但是两个方块的边界处会出现破裂,如图2-15所示。显示了图2-14右图的3D表示,你需要进行拼接修复这个破裂,这并不简单。 图2-15 基于方块的地形的不同细节层次会产生破裂 因此,这个教程会使用一个不同的三角形布局,不是处理每个方块,而是处理每个三角形。当一个三角形需要增加细节层次时,你只需将它分割成两个,如图2-16所示。这可以让你将两个三角形变成四个三角形。 图2-16 基于三角形布局增加细节层次 这个分割不会导致破裂。 在某些情况中,你可以继续使用这个方式分割三角形而不会产生破裂。例如,图2-17左图显示了另三个安全的分割。 图2-17 三个安全的分割(左),导致出现破裂的一次分割 但是,图2-17的右图显示了将三角形B分割成C和D会导致地形上出现破裂。图2-18显示了这个破裂的3D显示。 图2-18 由基于三角形布局的不同细节层次引起的破裂 幸运的是,你可以避免这个情况的发生,要避免在分割三角形时出现破裂,你可以进行以下操作: 确保父三角形也被分割 将共享长边的三角形也进行分割 图2-19显示了分割三角形A的过程。在左上图中,虚线表示你想进行的分割。根据第二条规则,共享长边的三角形(三角形B)需要首先被分割,如右上图的虚线所示。但是,根据第一条规则,在分割三角形B之前,你需要首先将三角形B的父三角形(三角形D)分割成B和C,如左中图的虚线所示。但在分割D之前,你需要首先分割三角形E。幸运的是,三角形E没有父三角形,所以你可以分割三角形E,之后分割D,如中右图所示。现在可以安全地分割B,如下左图所示,最后分割A,如下右图所示。 图2-19 在逐三角形布局中的分割过程 这就是分割过程。要使地形在相机移动时保持最优化(即使用尽可能少的三角形),除了可以分割三角形还需要可以合并它们。当两个三角形A和B合并在一起时,对面的两个三角形C和D也应该合并以避免产生破裂,如图2-20所示。 图2-20 逐三角形布局的合并过程 初始化 现在开始编写代码。首先加载包含高度图的图像并基于这个图像创建一个VertexBuffer。在这个教程中,图像的长和宽必须是(2的整数幂+1)。你要么创建一个这个大小的高度图,要么开始时使用大小为2的整数幂的高度图,然后手动添加最后一行和一列。为了将注意力放在后面的操作上,本教程假设你使用的就是(2的整数幂+1)大小的高度图,虽然你也可以在本教程的代码中发现LoadHeightDataAndCopyLastRowAndColumn方法。 在LoadContent方法中添加以下代码,加载图像并创建对应的VertexBuffer,解释可见教程5-8: Texture2D heightMap = Content.Load<Texture2D>("heightmap"); heightData = LoadHeightData(heightMap); myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices(); int[] terrainIndices = CreateTerrainIndices(); terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices); terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * terrainVertices.Length, BufferUsage.WriteOnly); terrainVertexBuffer.SetData(terrainVertices); Triangle类 你将创建一个Triangle类,它用来自己进行判断并作出动作。通过右击解决方案管理器中的项目并选择Add→New Item在你的项目中创建一个新类。在对话框中选择Class,命名为Triangle.cs。 首先需要定义一些变量,这些变量存储三个邻近、子和父的链接。 Triangle lNeigh; Triangle rNeigh; Triangle bNeigh; Triangle parent; Triangle lChild; Triangle rChild; 图2-21显示了它们之间的关系。 图2-21 三角形之间的关系 你还需要另外一些变量: Vector3 lPos; Vector3 apexPos; int tInd; int lInd; int rInd; public bool splitted = false; public bool addedToMergeList = false; 两个Vector3保存left点和right点的3D位置(见图2-21),用来判断是否需要分割这个三角形。索引用来绘制三角形。Spiltted表示三角形是否分割,addedToMergeList用来确保某些计算不要在同一个三角形上重复两次。 还有一些变量在构造函数中定义,构造函数在创建一个新的Triangle对象时调用: public Triangle(Triangle parent, Vector2 tPoint, Vector2 lPoint, Vector2 rPoint, float[,] heightData) { int resolution = heightData.GetLength(0); tInd = (int)(tPoint.X + tPoint.Y * resolution); lInd = (int)(lPoint.X + lPoint.Y * resolution); rInd = (int)(rPoint.X + rPoint.Y * resolution); lPos = new Vector3(lPoint.X, heightData[(int)lPoint.X, (int)lPoint.Y], -lPoint.Y); Vector2 apex = (lPoint + rPoint) / 2; apexPos = new Vector3(apex.X, heightData[(int)apex.X, (int)apex.Y], -apex.Y); this.parent = parent; if (Vector2.Distance(lPoint, tPoint) > 1) { lChild = new Triangle(this, apex, tPoint, lPoint, heightData); rChild = new Triangle(this, apex, rPoint, tPoint, heightData); } } 如你所见,当创建一个新Triangle对象时,需要指定它的父、左上点和右上点。而且,因为三角形结构用来绘制一个地形,它的每个点都代表一个3D点。所以当创建一个Triangle对象时,你还需要指定一个包含整个地形高度值的2D数组。这个数组首先用来获取三个角的索引(可见教程5-8学习如何获取一个网格顶点的索引),然后是对应左边角和中心点的3D位置。 最后,存储指向父三角形的链接,检测当前三角形是否可以分割。如果三角形足够大,你将创建两个子三角形并存储它们的链接。注意,中心点就是两个子三角形的顶部的点,可参见图2-21。 定义两个基三角形 定义了Triangle类的通用结构后,你就做好了定义两个基三角形的准备,这两个基三角形覆盖整个屏幕。首先在第一帧绘制这两个三角形。然后,但调用Update方法时,判断是否需要分割前一帧绘制的三角形。因此,你需要保存当前绘制的三角形的集合。在代码顶部添加这行代码: List<Triangle> triangleList; 然后转到LoadContent方法的最后,在那里我们将创建Triangle结构。首先定义一个resolution变量,它只是一个调试变量。通常,你想绘制一个与高度图一样大小的地形,但在调试时,只使用高度图一部分是很有用的。虽然本教程使用的高度图大小为1025×1025,但现在只使用33×33。 下面的代码定义左边的三角形: int terrainSize = 32; Triangle leftTriangle = new Triangle(null, new Vector2(0, 0), new Vector2(terrainSize, 0), new Vector2(0, terrainSize), heightData); 基三角形没有父三角形。你将三角形的三个顶点作为第二、第三、第四个参数传递,对应整个地形的顶点。最后,传递包含高度值的2D数组。 注意当代码被执行时,Triangle类的构造函数会被调用。这意味着这个三角形会自己创建两个子三角形,这两个子三角形也会创建自己的两个子三角形。这个过程持续到所有三角形的大小小于两个创建的高度图上的点。 右边的三角形以同样的方法定义,只是顶角的位置不同: Triangle rightTriangle = new Triangle(null, new Vector2(terrainSize, terrainSize), new Vector2(0, terrainSize), new Vector2(terrainSize, 0), heightData) 添加链接 对于所有在Triangle类中进行的判断和动作,每个Triangle都需要访问它的父、邻居和子三角形。因此,下一步就是添加它们的链接。 每个Triangle已经存储了指向父和子三角形的链接,但还需要知道邻近三角形的链接。技巧是知道三个邻近三角形就可以知道它的子三角形的三个邻近三角形。这个方法可以自动计算结构中所有三角形的邻近三角形。首先在Triangle类中添加这个方法: public void AddNeighs(Triangle lNeigh, Triangle rNeigh, Triangle bNeigh) { this.lNeigh = lNeigh; this.rNeigh = rNeigh; this.bNeigh = bNeigh; } Triangle需要你将链接传递到三个邻近三角形中并存储。然后,如果Triangle拥有子,你想找到子三角形的邻近三角形,这样你可以将调用传递到子三角形中,然后依次传递到它们的子三角形的邻近三角形中,直到整个结构的Triangles都存储了指向三个邻近三角形的链接。 if (lChild != null) { Triangle bNeighRightChild = null; Triangle bNeighLeftChild = null; Triangle lNeighRightChild = null; Triangle rNeighLeftChild = null; if (bNeigh != null) { bNeighLeftChild = bNeigh.lChild; bNeighRightChild = bNeigh.rChild; } if (lNeigh != null) lNeighRightChild = lNeigh.rChild; if (rNeigh != null) rNeighLeftChild = rNeigh.lChild; lChild.AddNeighs(rChild, bNeighRightChild, lNeighRightChild); rChild.AddNeighs(bNeighLeftChild, lChild, rNeighLeftChild); } 你要做的就是通过在两个基三角形上调用这个方法初始化这个过程。在LoadContent方法的最后添加下面的代码: leftTriangle.AddNeighs(null, null, rightTriangle); rightTriangle.AddNeighs(null, null, leftTriangle); 每个基三角形是另一个的唯一邻近三角形。这两个简单的调用会遍历整个结构直到所有的Triangle都存储了正确的指向邻近三角形的链接。 TriangleList和IndexBuffer 当初始化过程结束后,你就做好了继续前进的准备。对ROAM算法的每次迭代,都从上一帧绘制的三角形集合开始。在迭代过程中,你都需要更新这个集合,将需要更高细节的三角形进行分割(被它们的两个子三角形替代),细节太高的三角形与对应的邻近三角形合并(见图2-20)。当集合中的每个Triangle将它们的索引存储到一个集合后迭代结束,然后将索引集合上传到显卡的缓冲中。因此,在Triangle中需要一个小方法可以将三角形的索引添加到一个集合中: public void AddIndices(ref List<int> indicesList) { indicesList.Add(lInd); indicesList.Add(tInd); indicesList.Add(rInd); } 让我们回到主程序。因为你会频繁地上传索引集合,所以需要使用一个DynamicIndexBuffer(可见教程5-5)。因此,你需要在indicesList中保存本地索引的副本。在代码顶部添加以下三个变量: List<Triangle> triangleList; List<int> indicesList; DynamicIndexBuffer dynTerrainIndexBuffer; 然后,通过在集合中添加两个基三角形开始处理过程,通过将以下代码添加到LoadContent方法中将它们的索引存储到另一个集合中: triangleList = new List<Triangle>(); triangleList.Add(leftTriangle); triangleList.Add(rightTriangle); indicesList = new List<int>(); foreach (Triangle t in triangleList) t.AddIndices(ref indicesList); 现在有了索引集合,你就做好了将集合发送到DynamicsIndexBuffer的准备: dynTerrainIndexBuffer = new DynamicIndexBuffer(device, typeof(int), indicesList.Count, BufferUsage.WriteOnly); dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard); dynTerrainIndexBuffer.ContentLost += new EventHandler(dynIndexBuffer_ContentLost); 如教程5-5的解释,你需要在设备丢失时调用一个指定的方法。这意味着你还需要指定dynIndexBuffer_ContentLost方法,它可以将索引从本地内存复制到显卡中: private void dynIndexBuffer_ContentLost(object sender, EventArgs e) { dynTerrainIndexBuffer.Dispose(); dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard); } 绘制TriangleList中的三角形 以后,你会从triangleList中添加或移除三角形。现在,首先定义一个简单的方法绘制包含在triangleList的三角形。解释请见教程5-8。 private void DrawTerrain() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); device.RenderState.FillMode = FillMode.Solid; device.RenderState.AlphaBlendEnable = false; basicEffect.World = Matrix.Identity; basicEffect.View = fpsCam.ViewMatrix; basicEffect.Projection = fpsCam.ProjectionMatrix; basicEffect.Texture = grassTexture; basicEffect.TextureEnabled = true; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes); device.Indices = dynTerrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; int noTriangles = triangleList.Count; device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, width * height, 0, noTriangles); pass.End(); } basicEffect.End(); } 别忘了在Draw方法中调用这个方法: DrawTerrain(); device.Indices = null; 需要最后一行代码的原因是:当在显卡上将DynamicIndexBuffer设置为active时你无法改变其中的内容。 ROAM迭代 完成了初始化三角形结构和将triangleList中的三角形绘制到屏幕后,你就做好了进入ROAM的准备。每次迭代过程中,你首先从上一个迭代中绘制的三角形开始,需要根据当前相机的位置判断三角形是否需要分割还是合并。下面是包含在ROAM迭代中的步骤。 注意在迭代开始时除了triangleList这些集合是空的,显然不包括triangleList。看起来好像可以将一些步骤合并在一起,实际上不能,理由会在下面的段落中解释。 首先从前一次迭代中计算的triangleList开始。 对集合中的每个三角形,检测是否需要被分割成两个子三角形,将它们添加到splitList集合中。保存不需要分割的三角形到remainderList集合。 分割splitList中的三角形,每个三角形会将它的两个子三角形添加到newTriangleList中,在迭代最后newTriangleList会成为新的triangleList。 检测remainderList中的三角形是否需要合并,如果是,将它的父三角形添加到mergeList中。将其他的三角形添加到leftoverList中。 将mergeList中的每个父三角形添加到newTriangleList中。 如果需要,将leftoverList中的每个三角形添加到newTriangleList中。 在triangleList中保存newTriangleList做好用于下一次迭代的准备。 将triangleList中的三角形的索引传递到DynamicIndexBuffer中,将这些三角形绘制到屏幕上。 整个过程(除了第8步)由UpdateTriangles方法处理。显然你需要编写用于UpdateTriangles方法的方法,UpdateTriangles方法需要放置在游戏主类中,因为它需要访问triangleList。 private void UpdateTriangles() { List<Triangle> splitList = new List<Triangle>(); List<Triangle> mergeList = new List<Triangle>(); List<Triangle> remainderList = new List<Triangle>(); List<Triangle> leftoverList = new List<Triangle>(); List<Triangle> newTriangleList = new List<Triangle>(triangleList.Count); Matrix worldViewProjectionMatrix = Matrix.Identity * fpsCam.ViewMatrix * fpsCam.ProjectionMatrix; BoundingFrustum cameraFrustum = new BoundingFrustum(worldViewProjectionMatrix); foreach (Triangle t in triangleList) t.CreateSplitList(ref splitList, ref remainderList, ref worldViewProjectionMatrix, ref cameraFrustum); foreach (Triangle t in splitList) t.ProcessSplitList(ref newTriangleList); foreach (Triangle t in remainderList) t.CreateMergeList(ref mergeList, ref leftoverList, ref worldViewProjectionMatrix, ref cameraFrustum); foreach (Triangle t in mergeList) t.ProcessMergeList(ref newTriangleList, ref worldViewProjectionMatrix, ref cameraFrustum); foreach (Triangle t in leftoverList) t.ProcessLeftovers(ref newTriangleList); triangleList = newTriangleList; triangleList.TrimExcess(); } 创建splitList 在创建splitList前,每个Triangle需要判断本身是否需要分割。一个Triangle是基于自己左顶点和中心点之间的屏幕距离的(见图2-21)。屏幕距离是两点间的屏幕像素大小,靠近相机的三角形这个距离会比远离相机的三角形大。只有这个屏幕距离大于某个阈值时这个三角形才需被分割,这是由ShouldSplit方法决定的,它应该放置在Triangle类中: public bool ShouldSplit(ref Matrix wvp, ref BoundingFrustum bf) { bool shouldSplit = false; if (bf.Contains(apexPos) != ContainmentType.Disjoint) { Vector4 lScreenPos = Vector4.Transform(lPos, wvp); Vector4 aScreenPos = Vector4.Transform(apexPos, wvp); lScreenPos /= lScreenPos.W; aScreenPos /= aScreenPos.W; Vector4 difference = lScreenPos - aScreenPos; Vector2 screenDifference = new Vector2(difference.X, difference.Y); float threshold = 0.1f; if (screenDifference.Length() > threshold) shouldSplit = true; } return shouldSplit; } 首先检查中心点是否在相机视野中(见教程2-5)。如果不是,无需分割这个三角形。如果是,你找到左顶点和中心点的屏幕位置,这可以在每个3D顶点shader中进行:通过使用WorldViewProjection矩阵转换3D位置。因为结果是一个Vector4,你需要将XYZ分量除以W分量。结果中的XY坐标包含了2D屏幕位置的坐标,位于[-1,1]区间。所以,你获取了XY坐标,通过将左顶点和中心点相减获取屏幕距离,screenDifference.Length()。 只有当screenDifference.Length()大于指定的阈值时才需要分割三角形。这意味着小于threshold变量的值会增加地形的细节。 注意:在这种情况中,如果距离小于屏幕大小的1/20,三角形会分割。这个阈值还是比较高。可见教程的最后可以下关于这点的讨论。 现在,每个三角形都判断了自己是否需要分割,你几乎已经做好了创建splitList的准备了。但是,你还需记得本教程前面介绍中的讨论:当三角形需要分割时,你还需要分割它的邻近三角形和它的父三角形(见图2-19)。可以使用下面的方法做到这点,这个方法应该添加到Triangle类中: public void PropagateSplit(ref List<Triangle> splitList) { if (!splitted) { splitted = true; splitList.Add(this); if (bNeigh != null) bNeigh.PropagateSplit(ref splitList); if (parent != null) parent.PropagateSplit(ref splitList); } } 这个方法将split变量设为true,将当前的三角形添加到splitList中,然后递归调用其底部三角形和父三角形。 因为递归,所以步骤CreateSplitList和ProcessSplits无法同时进行;否则,你会在newTriangleList中添加一个三角形,而因为这个三角形的底部三角形或子三角形要进行分割,导致这个三角形也会进行分割。这会导致这个三角形和它的子三角形都会被绘制。 注意:首先检查split变量是否为false,只要可能就将它设置为true是很重要的。否则,这个方法会一直循环下去,因为共享长边的两个三角形会一直互相调用这个方法。 现在,你终于做好了编写CreateSplitList方法的准备,它由程序主类中的UpdateTriangles方法调用: public void CreateSplitList(ref List<Triangle> splitList, ref List<Triangle> remainderList, ref Matrix wvp, ref BoundingFrustum bf) { bool hasSplit = false; if ((lChild != null) && (!splitted)) { if (ShouldSplit(ref wvp, ref bf)) { PropagateSplit(ref splitList); hasSplit = true; } } if (!hasSplit) remainderList.Add(this); } 如果三角形还没有分割,可以被分割而且应该被分割,则调用它的PropagateSplit方法。否则将它添加到remainderList中。 处理splitList 下一步获取需要被分割的三角形的集合,编写ProcessSplitList方法很简单。每个需要分割的三角形需要将它的两个子三角形添加到newTriangleList中。但是,因为递归的缘故,可能一个或两个子三角形会被自己分割。这样的子三角形不应该被添加到newTriangleList中,因为当调用子三角形的ProcessSplitList方法时它会自己处理自己的子三角形。 在Triangle类中添加ProcessSplitList方法: public void ProcessSplitList(ref List<Triangle> toDrawList) { if (!rChild.splitted) toDrawList.Add(rChild); if (!lChild.splitted) toDrawList.Add(lChild); } 创建meigeList 接下来,在remainderList中的每个三角形需要检测它的父三角形是否需要合并。根据教程前面的介绍和图2-20,父三角形首先需要进行一些检测判断它是否可以被合并。这是由CanMerge方法决定的,要在Triangle类中添加这个方法: public bool CanMerge() { bool cannotMerge = false; if (lChild != null) cannotMerge |= lChild.splitted; if (rChild != null) cannotMerge |= rChild.splitted; if (bNeigh != null) { if (bNeigh.lChild != null) cannotMerge |= bNeigh.lChild.splitted; if (bNeigh.rChild != null) cannotMerge |= bNeigh.rChild.splitted; } return !cannotMerge; } 根据介绍中的讨论,当子三角形被分割或子三角形的底部三角形被分割时,父三角形不允许被合并。 下面的方法检查一个三角形是否可以合并: public bool CheckMerge(ref List<Triangle> mergeList, ref Matrix wvp, ref BoundingFrustum bf) { bool shouldMerge = false; if (!addedToMergeList) { if (CanMerge()) { if (!ShouldSplit(ref wvp, ref bf)) { shouldMerge = true; if (bNeigh != null) if (bNeigh.ShouldSplit(ref wvp, ref bf)) shouldMerge = false; } } } } 因为实际上是子三角形决定父三角形是否可以合并,在迭代过程中这个方法会被调用两次。因此,shouldMerge变量用来表示三角形是否已经被添加到了mergeList中。 如果不是,你首先检测三角形是否可以合并,如果允许,检测它是否应该合并,换句话说,它是否不应该被分割。 最后,因为四舍五入的错误,有可能三角形决定要分割而底部三角形却没有,这会导致出现破裂。因此,你要确保两个当前三角形和它的底部三角形对合并的判断是相同的(见图2-20)。 如果三角形应该被合并,则执行下面的代码,应该把它放在CheckMerge方法的最后: if (shouldMerge) { addedToMergeList = true; mergeList.Add(this); if (bNeigh != null) { bNeigh.addedToMergeList = true; mergeList.Add(bNeigh); } } return addedToMergeList; 开始时将addedToMergeList变量设为true,这样当三角形被它的第二个子三角形调用时就不会重复执行这个方法。然后,将三角形添加到mergeList中。之后,如果三角形有底部邻近三角形,你还要将这个邻近三角形添加到集合中以防止产生破裂。你还要将它的addedToMergeList变量设为true,这样方法才不会重复进行。 在某些情况中,你已经将addedToMergeList设为true了,但仍需要在迭代的一开始将它重置为false。理想的地方是放在CreateSplitList方法中,因为这个方法被triangleList中的所有三角形调用,将这行代码添加到CreateSplitList方法的顶部: addedToMergeList = false; 定义了两个方法后,很容易编写CreateMergeList的代码,它被主程序代码调用,将它添加到Triangle类中: public void CreateMergeList(ref List<Triangle> mergeList, ref List<Triangle> leftoverList, ref Matrix wvp, ref BoundingFrustum bf) { bool cannotMerge = true; if (parent != null) cannotMerge = !parent.CheckMerge(ref mergeList, ref wvp, ref bf); if (cannotMerge) leftoverList.Add(this); } 如果当前三角形有一个父,则调用它的CheckMerge方法。如果允许并且必须,父三角形和底部邻近三角形会被添加到MergeList中。如果不是,子三角形会被添加到leftoverList中。 处理mergeList 幸运的是,处理需要合并的父三角形要干的事不多,只需将它们的split变量重置为false,将它们添加到newTriangleList中: public void ProcessMergeList(ref List<Triangle> toDrawList, ref Matrix wvp, ref BoundingFrustum bf) { splitted = false; toDrawList.Add(this); } 处理leftoverList 处理leftoverList还要简单,因为只需将它们添加到newTriangleList中即可。但是,因为split的递归,可能会发生当前三角形同时在分割,所以首先要检查一下: public void ProcessLeftovers(ref List<Triangle> toDrawList) { if (!splitted) toDrawList.Add(this); } 更新索引 前面的部分实现了全部的ROAM功能,现在要绘制的三角形存储在triangleList中。你还需要获取它们的索引并将它们传递到显卡上的DynamicIndexBuffer中。在程序主类中添加一些代码: private void UpdateIndexBuffer() { indicesList.Clear(); foreach (Triangle t in triangleList) t.AddIndices(ref indicesList); if (dynTerrainIndexBuffer.SizeInBytes / sizeof(int) < indicesList.Count) { dynTerrainIndexBuffer.Dispose(); dynTerrainIndexBuffer = new DynamicIndexBuffer(device, typeof(int), indicesList.Count, BufferUsage.WriteOnly); } dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard); } 第一行代码获取索引。然后,将它们传递到DynamicIndexBuffer中。但是,需要花点时间重新建立DynamicIndexBuffer。因此,首先检查当前缓冲是否可以容纳索引集合。只有当缓冲太小时才需要重新建立一个新的DynamicIndexBuffer。你将缓冲的大小除以一个整数值占据的大小知道这个缓冲可以容纳多少个整数。 注意:可参见教程5-5学习更多关于创建和使用DynamicIndexBuffer的细节。 使用方法 在Update方法中,确保调用UpdateTriangles方法更新triangleList,调用UpdateIndexBuffer方法将索引传递到显卡: UpdateTriangles(); UpdateIndexBuffer(); 这就是基本ROAM的实现方法。本教程余下的部分会教你如何使用正交投影矩阵调试三角形结构,还会讨论到性能。 正交投影矩阵 因为ROAM三角形比其他方法更加复杂,你需要一个强大的方法实时显示究竟发生了什么,想实现如图2-22所示的效果。 图2-22 绘制在ROAM地形之上的正交网格 在图2-22中你可以看到两样东西。首先是一个延伸到很远处的ROAM地形,靠近相机的山有一个漂亮的细节。第二,在其之上白色的三角形边框,它们是与绘制地形相同的三角形,但有两个不同: 它们只绘制边框 它们是从地形顶部观察的 看一下白色边框,你可以说出相机在地形的何处并看向何方:靠近相机的地方,绘制了大量的三角形,对应左下角的白色区域。在相机视野中的地形会随着离开相机的距离增大而减少细节。相机视野之外的地形会以尽可能少的三角形被绘制。 在本节中,你将学习如何绘制网格。因为你已经知道了三角形的索引,只需再次绘制同样的三角形即可,只不过使用一个不同的视矩阵和投影矩阵(见教程2-1)。 你只需简单地将相机的位置定义在地形中央的高处看向下方,但这样做不会获得图2-22中的效果。理由是当使用一个常规的投影矩阵时,靠近屏幕边缘的三角形会比靠近中央的三角形看起来小,这是由相机视锥体的金字塔形状导致的,如图2-23左图所示。这个投影导致物体靠近相机时会变大。在左图中的两点,靠近相机的那个点看起来会比视景体后部的那个点大。如果观察的是网格,一个常规的投影矩阵会导致靠近屏幕边缘的网格发生弯曲。 图2-23 常规相机视景体(左)和正交相机视景体(右) 你想要的视景体如图2-23的右图所示,叫做正交视景体。在这个视景体中相同大小的物体占据屏幕上的相同大小的区域:图2-23右图中的两个点在屏幕上看起来的大小是一样的。 创建视矩阵和投影矩阵 首先在代码顶部添加这两个矩阵: Matrix orthoView; Matrix orthoProj; 因为相机不会改变位置和朝向,你可以安全地在LoadContent方法中定义这两个矩阵无需改变它们: orthoView = Matrix.CreateLookAt(new Vector3(terrainSize / 2, 100, -terrainSize / 2), new Vector3(terrainSize / 2, 0, -terrainSize / 2), Vector3.Forward); orthoProj = Matrix.CreateOrthographic(terrainSize, terrainSize, 1, 1000); 因为实际上只改变相机的镜头,视矩阵定义与往常一样:你将相机放置在地形中央的上方,让它看向地形中央。因为你会使用一个正交投影矩阵,相机离开地形的高度无关紧要:三角形大小一样,与离开相机的位置无关。 如你所见,定义一个正交投影矩阵非常简单:只需将视景体的宽和高设置为World的大小,至于后面的两个值,你只需保证地形位于两者之间即可。 定义了两个矩阵后,准备好三角形和索引后,就可以绘制网格了。 绘制正交网格 因为绘制地形的三角形与绘制网格的三角形是一样的,你可以使用与绘制地形几乎一样的方法绘制网格,最重要的改变时你使用的是新建的视矩阵和投影矩阵。而且,将FillMode设置为WireFrame,这样你只需要绘制三角形的边框: private void DrawOrthoGrid() { int width = heightData.GetLength(0); int height = heightData.GetLength(1); device.RenderState.FillMode = FillMode.WireFrame; basicEffect.World = Matrix.Identity; basicEffect.View = orthoView; basicEffect.Projection = orthoProj; basicEffect.TextureEnabled = false; device.RenderState.AlphaBlendEnable = true; float color = 0.4f; device.RenderState.BlendFactor = new Color(new Vector4(color, color, color, color)); device.RenderState.SourceBlend = Blend.BlendFactor; device.RenderState.DestinationBlend = Blend.InverseBlendFactor; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.Vertices[0].SetSource(terrainVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes); device.Indices = dynTerrainIndexBuffer; device.VertexDeclaration = myVertexDeclaration; int noTriangles = indicesList.Count / 3; device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, width * height, 0, noTriangles); pass.End(); } basicEffect.End(); device.RenderState.AlphaBlendEnable = false; } 因为白色容易干扰视线,你想与地形进行颜色混合。这里BlendFactor渲染状态非常有用:因为你不能定义每个顶点的alpha值(或者你不得不定义一个自定义VertexFormat和顶点着色器),只能对所有三角形使用固定的alpha值。BlendFactor接受一个颜色,每个颜色都会被BlendFactor颜色添加透明效果。别忘了在Draw方法中绘制了地形之后调用这个方法: DrawOrthoGrid(); 代码 所有的代码前面已经写过了。 扩展阅读 关于LOD地形算法可以写一本书。虽然前面的代码已经提供了一个完整的ROAM功能,我还是可以进行大量的改进。为了让你可以思考一些东西,我会简短的讨论一些明显的性能改进。 在前面的代码中,ROAM过程开始于Update方法。这意味着整个过程每秒执行60次,这太频繁了。这有在相机移动或旋转时才需要执行ROAM,大多数情况中,这样做会将计算开销降低到一个可以忍受的水平。 而且,ROAM过程最好分成不同的步骤。在第一个周期中,你可以创建splitList,在下一个周期中处理这个splitList等。复杂的过程(例如创建splitList)可以分成多个周期。这样可以让你指定每帧用于计算的最大时间,这可以让你将资源合理分配给ROAM。 最后,因为分割的原理,ROAM算法的每一步都可以分割成多个部分,可以在不同的线程中并行处理,这样算法就可以从多核处理器中获益。 这个方法也可以和四叉树组合起来,ROAM可以用来判断地形是否以高细节还是低细节绘制。

在3D世界中创建不同的相机模式——使用四叉树隐藏不在视野中的部分网格

clock 十月 23, 2010 09:42 by author alex
2.10 使用四叉树隐藏不在视野中的部分网格 问题 地形绘制是游戏中的一个重要组成部分。但是,使用教程5-8中的方法,当绘制一个很大的地形时会导致帧数下降。 解决方案 你可以使用一个四叉树简化绘制大地形的工作流程,四叉树类似于八叉树,因为你要将地形分割成更小的方块直到这些方块不大于指定的大小。 这个处理过程如图2-13左图所示。一个16 × 16大小的方块被分割成四个方块,每个小方块再被分割成四个方块。 使用四叉树的主要优点是当绘制地形时,你只需绘制在相机视野中的那些方块,这可以通过检测哪些方块与相机视景体相交做到,显示在图2-13的右图中,看得见的方块用灰色表示。 图2-13 将一个大方块分割成小方块(左图),与相机视景体相交的方块(右图) 四叉树是八叉树的简化版本。只不过一个方块只需要被分割成四个小方块,而一个八叉树节点需要被分割成八个子立方体。八叉树需要保存其中的所有物体的位置,而四叉树无需进行这个操作。 工作原理 创建一个新的类表示一个方块,即四叉树的一个节点: namespace BookCode { class QTNode { private BoundingBox nodeBoundingBox; private bool isEndNode; private QTNode nodeUL; private QTNode nodeUR; private QTNode nodeLL; private QTNode nodeLR; private int width; private int height; private GraphicsDevice device; private BasicEffect basicEffect; private VertexBuffer nodeVertexBuffer; private IndexBuffer nodeIndexBuffer; private Texture2D grassTexture; public static int NodesRendered; } } 下面讨论一下每个节点需要的变量。第一个是包围盒,它非常重要。它是最小的盒子,当前节点的所有顶点都在这个盒子中。每一帧中你都要检测当前盒子是否在相机的视野中,只有结果为true才会在Draw方法中绘制这个节点中的所有子节点。 当创建一个四叉树时,你必须指定节点的最大大小。变量isEndNode表示当前节点是否大于这个最大大小,如果大于,这个节点必须创建四个子节点,存储在nodeUL到nodeLR中,其中UL表示左上方的节点,LR表示右下方的节点。 如果当前节点没有子节点,当isEndNode为true时这个节点会绘制某些三角形,要进行这个操作,你需要知道它的长和宽。你还需要一个指向图形设备和BasicEffect的链接。此外,你还需要一个VertexBuffer、一个IndexBuffer和一个Texture用来绘制三角形(可参见教程5-8)。 调用节点的构造函数创建一个新节点: public QTNode(VertexPositionNormalTexture[,] vertexArray, GraphicsDevice device, Texture2D grassTexture, int maxSize) { this.device = device; this.grassTexture = grassTexture; basicEffect = new BasicEffect(device, null); width = vertexArray.GetLength(0); height = vertexArray.GetLength(1); nodeBoundingBox = CreateBoundingBox(vertexArray); isEndNode = width <= maxSize; isEndNode &= height <= maxSize; if (isEndNode) { VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); TerrainUtils.CreateBuffers(vertices, indices, out nodeVertexBuffer, out nodeIndexBuffer, device); } else { CreateChildNodes(vertexArray, maxSize); } } 一个节点需要一个2D数组包含所有的顶点,还有指向设备和纹理的链接,一个大小,当节点的大小小于这个大小时就会停止将自己分割成子节点。 指向设备和纹理的链接会立即存储在节点中。当前节点的长和宽从包含顶点的2D数组中获取。给定这个节点的所有顶点,你可以使用下面就有创建的CreateBoundingBox方法计算包围盒。 最后,你检查当前节点的宽和高是否小于最大大小。如果是,则从节点的顶点中创建一个VertexBuffer和一个IndexBuffer。如果大于最大大小,则将这个节点分割成四个子节点。 创建包围盒 给定包含顶点的2D数组,你可以很容易地将这些位置存储在一个集合中。BoundingBox 类的CreateFromPoints方法可以从位置集合中生成一个包围盒: private BoundingBox CreateBoundingBox(VertexPositionNormalTexture[,] vertexArray) { List<Vector3> pointList = new List<Vector3>(); foreach (VertexPositionNormalTexture vertex in vertexArray) pointList.Add(vertex.Position); BoundingBox nodeBoundingBox = BoundingBox.CreateFromPoints(pointList); return nodeBoundingBox; } 生成VertexBuffer和IndexBuffer 你可以使用教程5-8中的CreateVertices和CreateIndices方法,这两个方法根据给定的1D数组创建了一个VertexBuffer和一个IndexBuffer。这需要你首先将2D数组转换为一个1D数组,可以使用如下方法: private T[] Reshape2Dto1D<T>(T[,] array2D) { int width = array2D.GetLength(0); int height = array2D.GetLength(1); T[] array1D = new T[width * height]; int i = 0; for (int z = 0; z < height; z++) for (int x = 0; x < width; x++) array1D[i++] = array2D[x, z]; return array1D; } 这个泛型方法接受一个类型为T(本例中为VertexPositionNormalTexture)的2D数组,获取它的大小,并将它的内容复制到一个1D数组中。 注意:泛型功能是在.NET 2.0中引入的,当调用一个泛型方法时,你需要在括号内指定需要用哪种类型替换T,如下所示: VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray); 将节点分割成四个子节点 如果节点太大,它应该被分割成四个子节点。这里的难点是所有的子节点不都总是一样大小的。在图2-13的左图中,你看到一个16 × 16网格被分成四个8 × 8网格。如果每个子节点只存储8 × 8个顶点,就无法绘制在第8和第9行(列)之间的三角形,会留下空隙。在图2-13中,可以通过让第一行(列)的左上角节点大于其他节点解决这个问题。 对于不成对的方块,这个问题不存在:9 × 9方块可以被分成四个包含5 × 5顶点的方块,如图2-13的左上角方块所示。 剩下的问题是,你如何计算存储在每个方块中的顶点数量?比如,你怎么知道16应该分成9和8,而9分成5和5?第一个值可以将父节点的大小除以2获得,取小的整数(除以一个整数获得的结果就是你所期望的)再将结果加1。第二个值可以将父节点的大小除以2,取小的整数,然后从父节点的大小中减去这个结果。 例如,16除以2为8,加1为9,即第一个值。然后16除以2为8,16减8为8。 第二个例子中:9除以2为4.5,取4再加1为5,即第一个值。然后,9除以2为4.5,取4,将9减去4为5。 知道了子节点的大小,你就可以使用下列代码创建子节点了。对每个子节点,你首先复制顶点的大小,创建一个QTNode对象: private void CreateChildNodes(VertexPositionNormalTexture[,] vertexArray, int maxSize) { VertexPositionNormalTexture[,] ulArray = new VertexPositionNormalTexture[width / 2 + 1, height / 2 + 1]; for (int w = 0; w < width / 2 + 1; w++) for (int h = 0; h < height / 2 + 1; h++) ulArray[w, h] = vertexArray[w, h]; nodeUL = new QTNode(ulArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] urArray = new VertexPositionNormalTexture[width - (width / 2), height / 2 + 1]; for (int w = 0; w < width - (width / 2); w++) for (int h = 0; h < height / 2 + 1; h++) urArray[w, h] = vertexArray[width / 2 + w, h]; nodeUR = new QTNode(urArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] llArray = new VertexPositionNormalTexture[width / 2 + 1, height - (height / 2)]; for (int w = 0; w < width / 2 + 1; w++) for (int h = 0; h < height - (height / 2); h++) llArray[w, h] = vertexArray[w, height / 2 + h]; nodeLL = new QTNode(llArray, device, grassTexture, maxSize); VertexPositionNormalTexture[,] lrArray = new VertexPositionNormalTexture[width - (width / 2), height - (height / 2)]; for (int w = 0; w < width - (width / 2); w++) for (int h = 0; h < height - (height / 2); h++) lrArray[w, h] = vertexArray[width / 2 + w, height / 2 + h]; nodeLR = new QTNode(lrArray, device, grassTexture, maxSize); } 绘制四叉树 实现了创建和分割功能后,你就做好了绘制四叉树的准备。你需要确保只绘制在相机视野中的方块。在主程序中,你需要只调用四叉树的根节点的Draw方法绘制所有在相机视野中的节点。 需要检查根节点是否在相机视野中。如果不是,无需进行任何操作。如果是,这个根节点需要传递到四个子节点的Draw调用中。 每个子节点进行同样的操作:检测是否在相机视野中,如果是,将它们传递到他们的子节点的调用中直至最小的节点也被绘制。如果在视野中,会从它们的顶点中绘制一个网格: public void Draw(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustum) { BoundingBox transformedBBox = XNAUtils.TransformBoundingBox(nodeBoundingBox, worldMatrix); ContainmentType cameraNodeContainment = cameraFrustum.Contains(transformedBBox); if (cameraNodeContainment != ContainmentType.Disjoint) { if (isEndNode) { DrawCurrentNode(worldMatrix, viewMatrix, projectionMatrix); nodeUL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeUR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeLL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); nodeLR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum); } } } 这个方法需要传递相机的视景体,这个视景体用来检测相机是否与节点的包围盒相交,表示节点是否在相机视野中。 如果这个调用到达了最后一个节点,会调用DrawCurrentNode方法,这个方法会绘制指定节点。这个代码来自于教程5-8: private void DrawCurrentNode(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix) { basicEffect.World = worldMatrix; basicEffect.View = viewMatrix; basicEffect.Projection = projectionMatrix; basicEffect.Texture = grassTexture; basicEffect.VertexColorEnabled = false; 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(nodeVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes); device.Indices = nodeIndexBuffer; device.VertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements); device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, (width * 2 * (height - 1) - 2)); pass.End(); } basicEffect.End(); NodesRendered++; //XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, worldMatrix, viewMatrix, projectionMatrix); } 每次当代码执行时,NodeRendered变量会加1。因为它是一个静态变量,被四叉树的所有节点共享,所以在绘制过程的最后,它会包含实际绘制的节点的数量。 你可以注释掉最后一行代码,它可以绘制所有被绘制的节点的包围盒对象的边框。 初始化四叉树 完成了QTNode类后,你就做好了创建四叉树的准备。大部分代码与教程5-8中的相似,从一个包含高度数据的2D纹理开始,然后创建一个VertexPositionNormalTexture元素的1D数组。因为你想使用GenerateNormalsFromTriangleStrip方法,解释请见教程5-7,需要添加正确的法线,首先需要创建一个索引集合将下列代码添加到LoadContent方法中: Texture2D grassTexture = content.Load<Texture2D>("grass"); Texture2D heightMap = content.Load<Texture2D>("heightmap"); int width = heightMap.Width; int height = heightMap.Height; float[,] heightData = TerrainUtils.LoadHeightData(heightMap); VertexPositionNormalTexture[] vertices = ~ TerrainUtils.CreateTerrainVertices(heightData); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices); VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height); rootNode = new QTNode(vertexArray, device, grassTexture, 64); 最后你获得一个1D的顶点数组。但是QTNode的构造函数需要一个2D数组,所以最后一行代码调用Reshape1Dto2D方法: private T[,] Reshape1Dto2D<T>(T[] vertices, int width, int height) { T[,] vertexArray = new T[width, height]; int i=0; for (int h = 0; h < height; h++) for (int w = 0; w < width; w++) vertexArray[w, h] = vertices[i++]; return vertexArray; } 这仍是一个泛型方法,让你可以将1D数组转换为一个2D数组。有了顶点的2D数组,将最后一行代码添加到LoadContent方法中: rootNode = new QTNode(vertexArray, device, grassTexture, 64); 这行代码生成整个四叉树。你传入一个顶点2D数组和大小为64的最大大小,只要方块的大小大于64,它们就会被分割成子节点。 使用四叉树 在XNA项目中,你可以在Draw方法中放置以下代码: QTNode.NodesRendered = 0; BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered); 第一行和最后一行代码只用于调试,因为它们会将实际绘制的方块数量显示在窗口的标题栏中。 第二行代码创建了相机的视景体,用于检测四叉树的节点是否在相机视野中。第三行代码初始化Draw调用,Draw方法会遍历所有节点并绘制可见的节点。 代码 你可以在前面看到QTNode类的所有代码,下面是XNA主项目中的LoadContent方法: protected override void LoadContent() { device = graphics.GraphicsDevice; cCross = new CoordCross(device); Texture2D grassTexture = content.Load<Texture2D>("grass"); Texture2D heightMap = content.Load<Texture2D>("heightmap"); int width = heightMap.Width; int height = heightMap.Height; float[,] heightData = TerrainUtils.LoadHeightData(heightMap); VertexPositionNormalTexture[] vertices = TerrainUtils.CreateTerrainVertices(heightData); int[] indices = TerrainUtils.CreateTerrainIndices(width, height); vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices); VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height); rootNode = new QTNode(vertexArray, device, grassTexture, 64); } Draw方法会绘制所有可见节点: protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix); QTNode.NodesRendered = 0; BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered); base.Draw(gameTime); } 扩展阅读 这个教材介绍了四叉树的基本知识,但这里用到的四叉树有几个缺点: 加载时间过长。 DrawPrimitives调用次数很大,拖慢了程序。 如相机在地形上平视,仍会绘制大量的方块。 以上问题有多个解决方法,我没法一一列举,这样可能又可以写一本书了。但是,我会讨论产生这些问题的原因。 加载时间过长是由每次将一个方块分割成四个小方块时将顶点复制到一个小数组中引起的,重建过程会导致巨大的开销。这个问题可以通过在内容管道中生成四叉树加以解决,这样在运行过程中只需从二进制文件中读取顶点缓冲和索引缓冲流。可见教程5-13学习如何(反)串行化地形。 第二个问题的原因是显卡喜欢进行持续的工作不要被打搅,一次绘制一百万的三角形比1000次绘制1000个三角形好得多。你可以通过减少DrawPrimitives的调用次数解决这个问题。一个方法是检测一个父节点的所有四个子节点是否都可见,如果都可见,那么绘制父节点而不是四个子节点,这样绘制的三角形数量是一样的,但只需调用DrawPrimitives一次而不是四次。 最后一个问题会导致绘制极大数量的三角形,因为离开相机非常远的方块也会绘制与离相机非常近的方块相同数量的三角形,你可以通过以低细节绘制远处的方块解决这个问题,就是level-of-detail (LOD)算法,它是一个挑战。 另一个处理巨大地形的方法可参见下一个教程,请记住你可以组合这两个教程的技术,因为大多数地形引擎使用四叉树、ROAM引擎或两者的组合。例如,你可以将地形分成小块(patch),使用四叉树控制这些小块,然后使用下一个教程的ROAM算法绘制它们。这样就组合了四叉树的控制和ROAM的算法。

在3D世界中创建不同的相机模式——只绘制在相机视野中的物体:八叉树

clock 十月 23, 2010 09:35 by author alex
2.9 只绘制在相机视野中的物体:八叉树 问题 如果你使用教程2-5的方法检查相机视野中的每个模型,那么由于网格太多会极大地拖慢程序。你需要一个可扩展的方法决定场景的哪些部分需要被绘制。 解决方案 定义一个很大的立方体包含整个3D世界和所有模型。如果这个立方体包含太多模型,那么把这个立方体分割成小的子立方体。如果这些小的子立方体包含太多模型,那么再把这些立方体分割成更小的子立方体,直到立方体内包含的模型数量不超过某个特定值或立方体的大小足够小。 在绘制过程中,大立方体要求小立方体检查它们是否在相机视野中,如果子立方体不在视野中,则无需绘制之内的模型,如果在视野内并且子立方体还有自己的子立方体,则继续处理它的子立方体,直到这个子立方体在视野内并且没有在它下面没有子立方体。结果是,只有在视野内的最后的子立方体才会绘制在其内部的模型。 工作原理 首先你需要决定一个立方体内有多少个模型,本例中使用最多五个模型。只要已经包含了五个模型的立方体再增加一个模型,那么这个立方体必须被分成更小的子立方体,能分成全等的唯一方式就是分成八个小立方体,如图2-10所示。因为八个小立方体能完美地匹配它们的父立方体,而且每个子立方体能分成更小的立方体,形成一个树状结构。因为每个父节点有八个子节点,所以这种结构叫做八叉树。 图2-10 分成八个小立方体的大立方体 图2-11和图2-12显示了操作原理。 看一下图2-11,这个八叉树包含了超过五个模型,所以父立方体会分成八个子立方体,你还能看到有一个子立方体也包含了五个模型。 现在考虑这种情况:在一个已经包含五个模型的子立方体中再添加一个模型,那么这个立方体会分成八个立方体,六个模型会分布到相应的子立方体中,如图2-12所示。 图2-11 在添加了一个模型之前的八叉树 图2-12 添加了一个模型之后的八叉树(译者注:但图中显示添加了2个模型?) 使用八叉树的好处是主程序只需请求绘制根立方体,而立方体会自己判断是否在相机视野内。如果是,立方体会将这个调用传递到子立方体,如果这个子立方体包含模型则会绘制这些模型,或者将调用传递到下一层的子立方体中。通过这种方式,八叉树会自己决定需要绘制哪些节点。 DrawableModel类 为了正确地绘制模型,八叉树的每个立方体需要保存模型的世界矩阵,世界矩阵还包含模型在3D空间中的位置,所以保存世界矩阵和模型就足以让八叉树知道模型属于哪个子立方体了。 使用类可以使八叉树的代码编写变得容易。本例中你要创建一个叫做DrawableModel的新类,这个类会定义一个包含模型以及它的世界矩阵的对象。对每个存储在八叉树中的模型,会创建一个DrawableModel对象,这个对象存储了模型和它的世界矩阵,实际上是这个对象存储在了八叉树的立方体中。你也可以定义一个Draw方法,如果立方体检测到自己在相机视野内就会调用这个方法。 要创建一个新的类,应在Solution Explorer中右击项目名称并选择Add New Item,在对话框中选择Class并输入DrawableModel作为名称。在这个新类顶部添加XNA的引用: using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Storage; 我已经讨论过DrawableModel类的两个属性:Model和World矩阵。你还要存储变换矩阵实现动画 (见教程4-9),存储位置,存储ID用来识别对象。创建了DrawableModel类的实例后,这个对象会接受唯一的ID值,而这个ID会被传递到主程序中,允许你改变特定对象的世界矩阵。 所以在类中添加四个变量: class DrawableModel { private Matrix worldMatrix; private Model model; private Matrix[] modelTransforms; private Vector3 position; private int modelID; } DrawableModel类的构造函数有三个参数:Model,worldMatrix和ID。位置没有指定,因为位置是包含在世界矩阵中的,世界矩阵还包含缩放和旋转。 在类中添加如下的构造函数: public DrawableModel(Model inModel, Matrix inWorldMatrix, int inModelID) { model = inModel; modelTransforms = new Matrix[model.Bones.Count]; worldMatrix = inWorldMatrix; modelID = inModelID; position = new Vector3(inWorldMatrix.M41, inWorldMatrix.M42, inWorldMatrix.M43); } 你可以看到位置实际上是在世界矩阵的最后一行中。 注意:你也可以通过调用矩阵的Decompose方法获取位置,包含在矩阵中的变换的平移分量对应模型的位置。 接下来添加Draw方法,这将使用世界矩阵和变换矩阵绘制模型: public void Draw(Matrix viewMatrix, Matrix projectionMatrix) { model.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in model.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = viewMatrix; effect.Projection = projectionMatrix; } mesh.Draw(); } } Draw方法是此类的主要行为,但是,现在主程序还无法访问这些私有变量。你可以将这些变量设置为共有,但这样做不好,因为这样做让外部代码可以读写这些变量。好的方法是使用getter方法: public Vector3 Position { get{return position; } } public Model Model { get{return model; } } public int ModelID { get{return modelID; } } getter方法属于.NET 2.0,它只是简单地返回对应的属性,下面你就会看到它的使用。从现在开始,外部程序只能通过属性访问这些变量,但不能改变这些变量,这也是我们想要的结果。 还有一个变量不要从外部访问:worldMatrix。你必须提供给主程序一个更新对象世界矩阵的方法。所以不仅要定义getter方法还需定义一个setter方法。本例中setter方法的用处有两个:一是可以改变worldMatrix的值,二是从世界矩阵提取一个新的位置变量并把它存储在位置属性中。 public Matrix WorldMatrix { get { return worldMatrix; } set { worldMatrix = value; position = new Vector3(value.M41, value.M42, value.M43); } } getter方法允许主程序读取worldMatrix,setter方法从主程序接受新的值,这个值存储在 worldMatrix变量中,而位置信息是从这个变量中提取的。 以上就是定义DrawableModel类的过程。你定义一个类,这个类包含了模型和它的世界矩阵,可以使用当前世界矩阵绘制模型,主程序可以更新世界矩阵。 OcTreeNode类 现在你已定义了一个完整的DrawableModel类,下面就可以处理八叉树了。这个数是由对应八叉树的立方体的节点构成。你将定义第二个类:OcTreeNode,这个类表示了这个节点,每个节点可以存储DrawableModel对象并可以有8个子节点。 创建一个新的类:OcTreeNode。这个类包含一些属性: private const int maxObjectsInNode = 5; private const float minSize = 5.0f; private Vector3 center; private float size; List<DrawableModel> modelList; private BoundingBox nodeBoundingBox; OcTreeNode nodeUFL; OcTreeNode nodeUFR; OcTreeNode nodeUBL; OcTreeNode nodeUBR; OcTreeNode nodeDFL; OcTreeNode nodeDFR; OcTreeNode nodeDBL; OcTreeNode nodeDBR; List<OcTreeNode> childList; private static int modelsDrawn; private static int modelsStoredInQuadTree; 第一行代码定义了一个立方体可以包含几个模型。在将这个立方体分隔成更小的立方体之前,你需要检查下面的立方体是否太小,所以需要定义第二个属性minSize。 下一步,每个节点都要存储一些东西,诸如中心位置和大小。每个节点可以存储一些(5个)DrawableModel对象,这些对象存储在modelList集合中。要检查立方体是否在相机视野中,还需包含BoundingBox,这样才能进行立方体和相机视锥体的碰撞检测。 接下来是八个子节点的集合,我起了对应的名称,例如nodeUFL表示“node Upper Forward Left(左上角的节点),”nodeDBR表示“node Down Back Right。”当你查找对应位置的子立方体时会发现这种命名方式很有用。 你创建的每个OcTreeNode保存这些变量的一个副本,但最后两个变量是静态变量,这意味着这两个变量是被所有OcTreeNode对象共享的。所以当一个OcTreeNode对象改变了这两个变量,其他对象也会知道这个变化。你使用modelsDrawn变量检查八叉树是否工作,而 modelsStoredInQuadTree用来找到添加到树中的模型的新ID。 在这个类中添加的第一个方法是构造函数,它创建一个OcTreeNode对象,所需的只是新立方体的中心位置和大小: public OcTreeNode(Vector3 center, float size) { this.center = center; this.size = size; modelList = new List<DrawableModel>(); childList = new List<OcTreeNode>(8); Vector3 diagonalVector = new Vector3(size / 2.0f, size / 2.0f, size / 2.0f); nodeBoundingBox = new BoundingBox(center - diagonalVector, center + diagonalVector); } 中心和大小是存储在私有变量中的,同时也实例化了modelList和childList。 技巧:因为你知道每个立方体最多包含8个节点,所以你可以在构造函数中指定这个大小。一个List最好的特点是你无需指定它的大小,但如果你指定了,那么当在List中添加一个元素时无需重新改变大小。 最后,你创建了对应立方体的BoundingBox。 BoundingBox的构造函数接受两个点,这两个点是立方体的对角线上的两点,如果立方体的边长是10,中心在(0,0,0),那么你可以指定坐标为(-5,-5,-5) 和 (5,5,5)的两点。如果中心不在(0,0,0),你需要在两个点上再加上中心位置,这一步是在构造函数的最后一行代码中实现的。 现在可以定义创建8个子立方体的方法了,这个方法马上就要用到: private void CreateChildNodes() { float sizeOver2 = size / 2.0f; float sizeOver4 = size / 4.0f; nodeUFR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, -sizeOver4), sizeOver2); nodeUFL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, -sizeOver4), sizeOver2); nodeUBR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, sizeOver4), sizeOver2); nodeUBL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, sizeOver4), sizeOver2); nodeDFR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, -sizeOver4), sizeOver2); nodeDFL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, -sizeOver4), sizeOver2); nodeDBR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, sizeOver4), sizeOver2); nodeDBL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, sizeOver4), sizeOver2); childList.Add(nodeUFR); childList.Add(nodeUFL); childList.Add(nodeUBR); childList.Add(nodeUBL); childList.Add(nodeDFR); childList.Add(nodeDFL); childList.Add(nodeDBR); childList.Add(nodeDBL); } 首先创建8个子立方体并添加到childList中。如前所述,要创建一个节点,只需指定中心位置和大小。子立方体的边长是父立方体边长的一半。而找到子立方体的中心位置,你需要将父立方体的中心加上正确的向量。例如,要找到左上角的子立方体的中心,需要加上正Y(上)和负X和负Z的值(对应左和前)。 当父节点创建后,这些子节点不能自动建立,因为这些子节点还会包含自己的子节点。因此这个方法只有在把模型添加到节点并且节点已经包含最大数目的模型时才会被调用。这是在AddDrawableModel方法中处理的,下面是代码: private void AddDrawableModel(DrawableModel dModel) { if (childList.Count == 0) { modelList.Add(dModel); bool maxObjectsReached = (modelList.Count >maxObjectsInNode ); bool minSizeNotReached = (size > minSize); if (maxObjectsReached && minSizeNotReached) { CreateChildNodes(); foreach (DrawableModel currentDModel in modelList) { Distribute(currentDModel); } modelList.Clear(); } } else { Distribute(dModel); } } 这个方法以DrawableModel对象作为参数,这个对象需要被添加到节点中。首先,你检查这个节点是否有子节点,这可以从childList是否包含超过一个元素中判断。如果不是,则将DrawableModel对象添加到modelList中。做完此步,还要检查此节点是否包含太多的DrawableModel对象,以及这个立方体是否大到可以分隔成更小的子立方体。 如果以上皆是,你将创建子节点并将每个DrawableModel对象分配到(使用Distribute方法)对应的modelList中。你将在后面创建这个Distribute方法。当所有的模型分配到子节点后,清除modelList。否则,这个节点和它的子节点都会绘制这个模型,会导致模型绘制了两次。 如果这个节点已经分隔成了子节点,那么DrawableModel对象会立即分配到对应的子节点中。 困难的部分是在Distribute方法中进行的。这个方法通过比较传入的DrawableModel 对象的位置与父节点的中心位置将这个对象放置到对应的子节点中。其实不是很难,因为你可以立即访问到DrawableModel的位置: private void Distribute(DrawableModel dModel) { Vector3 position = dModel.Position; if (position.Y > center.Y) //Up if (position.Z < center.Z) //Forward if (position.X < center.X) //Left nodeUFL.AddDrawableModel(dModel); else //Right nodeUFR.AddDrawableModel(dModel); else //Back if (position.X < center.X) //Left nodeUBL.AddDrawableModel(dModel); else //Right nodeUBR.AddDrawableModel(dModel); else //Down if (position.Z < center.Z) //Forward if (position.X < center.X) //Left nodeDFL.AddDrawableModel(dModel); else //Right nodeDFR.AddDrawableModel(dModel); else //Back if (position.X < center.X) //Left nodeDBL.AddDrawableModel(dModel); else //Right nodeDBR.AddDrawableModel(dModel); } 直到现在你只定义了私有方法,这意味着主程序无法访问节点并添加模型。所以,你需要添加Add方法被主程序调用: public int Add(Model model, Matrix worldMatrix) { DrawableModel newDModel = new DrawableModel(model, worldMatrix, modelsStoredInQuadTree++); AddDrawableModel(newDModel); return newDModel.ModelID; } 这个方法只是从调用者接受模型和世界矩阵,它会创建一个存储这两个参数的DrawableModel对象,DrawableModel对象还接受它的ID,这来自于modelsStoredInQuadTree变量,当将一个模型添加到八叉树中时这个变量会加一。因为这个变量是静态的,被所有节点共享,所以这个变量总是增加的,每个新添加的DrawableModel都会有一个新的ID,这个新ID总是比前一个DrawableModel大1。接下来,新创建的DrawableModel对被添加到八叉树中。 ID需要返回到主程序,这很重要。 在本教程最后,你会看到一些代码绘制每个立方体的边界,这是要添加的最后一个功能:绘制整个八叉树。这个Draw方法需要viewMatrix,projectionMatrix和cameraFrustum。你可以从两个矩阵计算出cameraFrustum, 每个子立方体都需要进行这个计算,所以计算一次然后把它传递到子立方体会快得多。 public void Draw(Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustum) { ContainmentType cameraNodeContainment = cameraFrustum.Contains(nodeBoundingBox); if (cameraNodeContainment != ContainmentType.Disjoint) { foreach (DrawableModel dModel in modelList) { dModel.Draw(viewMatrix, projectionMatrix); modelsDrawn++; } foreach (OcTreeNode childNode in childList) childNode.Draw(viewMatrix, projectionMatrix, cameraFrustum); } } 你首先检查包围盒的相机视锥体的碰撞。如果没有碰撞,则这个立方体就没必要绘制,当然它的子立方体也无需绘制。 如果发生碰撞,则这个立方体中的所有DrawableModel都需被绘制。如果这个节点还包含子节点,那么Draw还要传递到这些子节点,并检查这些子节点是否在相机视锥体中 。 使用:主程序 这个教程理论上很漂亮,但如何使用呢?很简单,只需在主程序中定义一个变量: ocTreeRoot variable,这是包含所有立方体的父立方体: OcTreeNode ocTreeRoot; 在Initialize方法中进行初始化: ocTreeRoot = new OcTreeNode(new Vector3(0, 0, 0), 21); 对于立方体中心,你指定了3D世界的中心:(0,0,0) 。第二个参数是整个3D世界的大小。通常这个值大于我在本例中使用的21。 初始化八叉树后,你就可以添加模型了。这一步可以在加载模型后的任何地方进行。在下列代码中,我在LoadContent方法中完成此步,但你也可以在程序的其他地方写入下面的代码: myModel = content.Load<Model>("tiny"); int[] modelIDs = new int[8]; modelIDs[0] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 5, 5)); modelIDs[1] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -1)); modelIDs[2] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -1)); modelIDs[3] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 4, -3)); modelIDs[4] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -3)); modelIDs[5] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -3)); modelIDs[6] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(8, 8, -3)); modelIDs[7] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, 8, -3)) 上述代码将8个模型添加到八叉树中并将ID存储在modelIDs数组中。绘制过程中只需调用八叉树的Draw方法!这个调用会继续到所有的子立方体直到所用模型都被绘制。记住只有在相机视野中的立方体中的内容才会被绘制。 BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); 更新模型的世界矩阵 在程序运行时,你还想更小模型的世界矩阵。这也是为什么主程序需要在八叉树中保存模型ID的原因:你需要通过ID改变模型的世界矩阵。要扩展八叉树使它可以改变世界矩阵,需要在OcTreeNode类中添加两个方法,第一个是公有方法可以被主程序调用: public void UpdateModelWorldMatrix(int modelID, Matrix newWorldMatrix) { DrawableModel deletedModel = RemoveDrawableModel(modelID); deletedModel.WorldMatrix = newWorldMatrix; AddDrawableModel(deletedModel); } 注意:这个方法只用到了RemoveDrawableModel和AddDrawableModel方法。实际上,你不想在每次更新世界矩阵时都删除并创建一个新对象,你可以使用一些技巧,例如,将所有的矩阵存储在一个大的集合中,这样你就可以在主程序中直接更新集合中的内容。 如你所见,主程序通过ID和新的世界矩阵指定它想改变的模型。首先,ID被传递到RemoveDrawableModel方法,这个方法在八叉树中搜索具有这个ID的DrawableModel。如果找到,则从立方体的modelList 中移除这个DrawableModel并返回它。然后这个DrawableModel的世界矩阵被更新并重新添加到八叉树中。很好,显然难的是RemoveDrawableModel方法,下面是代码: private DrawableModel RemoveDrawableModel(int modelID) { DrawableModel dModel = null; for (int index = 0; index < modelList.Count; index++) { if (modelList[index].ModelID == modelID) { dModel = modelList[index]; modelList.Remove(dModel); } } int child = 0; while ((dModel == null) && (child < childList.Count)) { dModel = childList[child++].RemoveDrawableModel(modelID); } return dModel; } 这个方法首先通过ID检查这个模型是否在modelList中,如果是,这个DrawableModel会被存储到dModel变量并从modelList中移除。 注意:本例中不使用foreach循环遍历modelList,这是因为你要改变集合中的内容,而这在foreach中是不允许的。 如果找到模型,那么dModel就不再是null,所以while循环不被触发,DrawableModel被返回。 如果modelList中没有找到DrawableModel,你将检查八个子节点,这一步是在while循环中进行的,只要DrawableModel没有被找到而且八个子立方体没有被搜索,那么就会搜索下一个子节点。 很好,现在你可以在主程序中更新模型的世界矩阵了,这可以在update过程中实现: time += 0.01f; Vector3 startingPos = new Vector3(1, 5, 5); Vector3 moveDirection = new Vector3(0, 0, -1); Matrix newWMatrix = Matrix.CreateScale(0.01f, 0.01f,0.01f)*Matrix.CreateTranslation(startingPos+time*moveDirection); int modelToChange = 0; ocTreeRoot.UpdateModelWorldMatrix(modelToChange, newWMatrix); 上述代码可以让ID为0的模型移动到(0,0,-1)。 检查是否正常工作:绘制的模型数量 现在可以检查程序是否工作正常了。OcTreeNode类包含一个modelsDrawn变量,它是静态的,这意味着这个变量在所有节点中都是一样的,只有在模型被绘制的情况下这个变量才会在Draw方法中增加。 这是个私有变量,所以你无法在类外访问它。你可以将它变成公有,但这里我们使用getter+setter方法,将下列代码添加到OcTreeNode类中: public int ModelsDrawn { get { return modelsDrawn; } set { modelsDrawn = value; } } 在绘制过程中,你想在绘制八叉树前将这个值设置为0,当绘制完成,这个值会保存实际绘制到屏幕的模型数量并输出,例如,输出到窗口的标题栏中: ocTreeRoot.ModelsDrawn = 0; BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); Window.Title = string.Format("Models drawn: {0}", ocTreeRoot.ModelsDrawn); 当立方体中的模型离开相机视野时,你会发现这个数值会减小。 检查是否正常工作:绘制立方体的边界 如果你想检查八叉树的结构,你可以使用XNAUtils 类中的DrawBoundingBox方法绘制包围盒的边界: public void DrawBoxLines(Matrix viewMatrix, Matrix projectionMatrix, GraphicsDevice device, BasicEffect basicEffect) { foreach (OcTreeNode childNode in childList) childNode.DrawBoxLines(viewMatrix, projectionMatrix,device, basicEffect); if (childList.Count == 0) XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, Matrix.Identity, viewMatrix, projectionMatrix); } 这个方法检查节点是否包含子节点。如果是,将调用传递到每个子节点。当到达最后一个节点时,会使用DrawBoundingBox方法绘制它的包围盒。要绘制所用立方体的边,你只需调用根节点的DrawBoxLines方法,这个方法会深入到所有的子节点: ocTreeRoot.DrawBoxLines(fpsCam.GetViewMatrix(), fpsCam.GetProjectionMatrix(),basicEffect); 这里使用的绘制立方体包围盒的DrawBoundingBox方法是快捷但丑陋(dirty)的。说它丑陋是因为每帧都要重复的创建顶点和索引。说它快捷是因为不需要在节点中存储额外的变量,因此这个方法很容易被添加和移除,因为在很多场合中绘制包围盒的边界是有用的,所以我把它放在了XNAUtils类中: public static void DrawBoundingBox(BoundingBox bBox, GraphicsDevice device, BasicEffect basicEffect, Matrix worldMatrix,Matrix viewMatrix, Matrix projectionMatrix) { Vector3 v1 = bBox.Min; Vector3 v2 = bBox.Max; VertexPositionColor[] cubeLineVertices = new VertexPositionColor[8]; cubeLineVertices[0] = new VertexPositionColor(v1, Color.White); cubeLineVertices[1] = new VertexPositionColor(new Vector3(v2.X, v1.Y, v1.Z), Color.Red); cubeLineVertices[2] = new VertexPositionColor(new Vector3(v2.X,v1.Y, v2.Z), Color.Green); cubeLineVertices[3] = new VertexPositionColor(new Vector3(v1.X, v1.Y, v2.Z), Color.Blue); cubeLineVertices[4] = new VertexPositionColor(new Vector3(v1.X, v2.Y, v1.Z), Color.White); cubeLineVertices[5] = new VertexPositionColor(new Vector3(v2.X, v2.Y, v1.Z), Color.Red); cubeLineVertices[6] = new VertexPositionColor(v2, Color.Green); cubeLineVertices[7] = new VertexPositionColor(new Vector3(v1.X, v2.Y, v2.Z), Color.Blue); short[] cubeLineIndices = { 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7 }; basicEffect.World = worldMatrix; basicEffect.View = viewMatrix; basicEffect.Projection = projectionMatrix; basicEffect.VertexColorEnabled = true; device.RenderState.FillMode = FillMode.Solid; basicEffect.Begin(); foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, ertexPositionColor.VertexElements); device.DrawUserIndexedPrimitives<VertexPositionColor>(PrimitiveType.LineList, cubeLineVertices, 0, 8,cubeLineIndices, 0, 12); pass.End(); } basicEffect.End(); } 技巧:你可以将这个方法作为使用索引化的LineList的例子。首先,八个顶点存储在一个数组中,然后创建一个对应顶点数组的索引数组。索引数组中每两个数字定义一条线段,所以24个索引定义立方体的12条线段。绘制图元的更多信息请见教程5-1。 代码 DrawableModel类包含绘制3D模型的所有信息和方法: class DrawableModel { private Matrix worldMatrix; private Model model; private Matrix[] modelTransforms; private Vector3 position; private int modelID; public Matrix WorldMatrix { get { return worldMatrix; } set { worldMatrix = value; position = new Vector3(value.M41, value.M42, value.M43); } } public Vector3 Position { get { return position; } } public Model Model { get { return model; } } public int ModelID { get { return modelID; } } public DrawableModel(Model inModel, Matrix inWorldMatrix, int inModelID) { model = inModel; modelTransforms = new Matrix[model.Bones.Count]; worldMatrix = inWorldMatrix; modelID = inModelID; position = new Vector3(inWorldMatrix.M41, inWorldMatrix.M42, inWorldMatrix.M43); } public void Draw(Matrix viewMatrix, Matrix projectionMatrix) { model.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh in model.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.EnableDefaultLighting(); effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = viewMatrix; effect.Projection = projectionMatrix; } mesh.Draw(); } } } OcTreeNode类存储每个模型的一个链接,也包含检查节点是否在相机视野和添加/移除模型的所有功能: class OcTreeNode { private const int maxObjectsInNode = 5; private const float minSize = 5.0f; private Vector3 center; private float size; List<DrawableModel> modelList; private BoundingBox nodeBoundingBox; OcTreeNode nodeUFL; OcTreeNode nodeUFR; OcTreeNode nodeUBL; OcTreeNode nodeUBR; OcTreeNode nodeDFL; OcTreeNode nodeDFR; OcTreeNode nodeDBL; OcTreeNode nodeDBR; List<OcTreeNode> childList; public static int modelsDrawn; private static int modelsStoredInQuadTree; public int ModelsDrawn { get { return modelsDrawn;} set { modelsDrawn = value; } } public OcTreeNode(Vector3 center, float size) { this.center = center; this.size = size; modelList = new List(); childList = new List(8); Vector3 diagonalVector = new Vector3(size / 2.0f, size / 2.0f, size / 2.0f); nodeBoundingBox = new BoundingBox(center - diagonalVector, center + diagonalVector); } public int Add(Model model, Matrix worldMatrix) { DrawableModel newDModel = new DrawableModel(model, worldMatrix, modelsStoredInQuadTree++); AddDrawableModel(newDModel); return newDModel.ModelID; } public void UpdateModelWorldMatrix(int modelID, Matrix newWorldMatrix) { DrawableModel deletedModel = RemoveDrawableModel(modelID); deletedModel.WorldMatrix = newWorldMatrix; AddDrawableModel(deletedModel); } private DrawableModel RemoveDrawableModel(int modelID) { DrawableModel dModel = null; for (int index = 0; index < modelList.Count; index++) { if (modelList[index].ModelID == modelID) { dModel = modelList[index]; modelList.Remove(dModel); } } int child = 0; while ((dModel == null) && (child < childList.Count)) { dModel = childList[child++].RemoveDrawableModel(modelID); } return dModel; } private void AddDrawableModel(DrawableModel dModel) { if (childList.Count == 0) { modelList.Add(dModel); bool maxObjectsReached = (modelList.Count > maxObjectsInNode); bool minSizeNotReached = (size > minSize); if (maxObjectsReached && minSizeNotReached) { CreateChildNodes(); foreach (DrawableModel currentDModel in modelList) { Distribute(currentDModel); } modelList.Clear(); } } else { Distribute(dModel); } } private void CreateChildNodes() { float sizeOver2 = size / 2.0f; float sizeOver4 = size / 4.0f; nodeUFR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, -sizeOver4), sizeOver2); nodeUFL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, -sizeOver4), sizeOver2); nodeUBR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, sizeOver4), sizeOver2); nodeUBL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, sizeOver4), sizeOver2); nodeDFR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, -sizeOver4), sizeOver2); nodeDFL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, -sizeOver4), sizeOver2); nodeDBR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, sizeOver4), sizeOver2); nodeDBL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, sizeOver4), sizeOver2); childList.Add(nodeUFR); childList.Add(nodeUFL); childList.Add(nodeUBR); childList.Add(nodeUBL); childList.Add(nodeDFR); childList.Add(nodeDFL); childList.Add(nodeDBR); childList.Add(nodeDBL); } private void Distribute(DrawableModel dModel) { Vector3 position = dModel.Position; if (position.Y > center.Y) //Up if (position.Z < center.Z) //Forward if (position.X < center.X) //Left nodeUFL.AddDrawableModel(dModel); else //Right nodeUFR.AddDrawableModel(dModel); else //Back if (position.X < center.X) //Left nodeUBL.AddDrawableModel(dModel); else //Right nodeUBR.AddDrawableModel(dModel); else //Down if (position.Z < center.Z) //Forward if (position.X < center.X) //Left nodeDFL.AddDrawableModel(dModel); else //Right nodeDFR.AddDrawableModel(dModel); else //Back if (position.X < center.X) //Left nodeDBL.AddDrawableModel(dModel); else //Right nodeDBR.AddDrawableModel(dModel); } public void Draw(Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustrum) { ContainmentType cameraNodeContainment = cameraFrustrum.Contains(nodeBoundingBox); if (cameraNodeContainment != ContainmentType.Disjoint) { foreach (DrawableModel dModel in modelList) { dModel.Draw(viewMatrix, projectionMatrix); modelsDrawn++; } foreach (OcTreeNode childNode in childList) childNode.Draw(viewMatrix, projectionMatrix, cameraFrustrum); } } public void DrawBoxLines(Matrix viewMatrix, Matrix projectionMatrix, GraphicsDevice device, BasicEffect basicEffect) { foreach (OcTreeNode childNode in childList) childNode.DrawBoxLines(viewMatrix, projectionMatrix, device, basicEffect); if (childList.Count == 0) XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, Matrix.Identity, viewMatrix, projectionMatrix); } 在XNA项目中,你在八叉树中存储了所有模型以及它们的世界矩阵: protected override void LoadContent() { device = graphics.GraphicsDevice; cCross = new CoordCross(device); basicEffect = new BasicEffect(device, null); myModel = content.Load<Model>("content/tiny"); int[] modelIDs = new int[8]; modelIDs[0] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 5, 5)); modelIDs[1] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -1)); modelIDs[2] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -1)); modelIDs[3] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 4, -3)); modelIDs[4] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -3)); modelIDs[5] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -3)); modelIDs[6] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(8, 8, -3)); modelIDs[7] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, 8, -3)); } 将模型存储到八叉树后,你可以通过一个简单的调用绘制它们。传递一个视锥体,如果不在相机视野内,八叉树就不会绘制模型。 BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix); ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum); 如果你想更新模型的世界矩阵,只需使用前面已经定义的UpdateModelWorldMatrix 方法: ocTreeRoot.UpdateModelWorldMatrix(modelToChange, newWorldMatrix); 扩展阅读 本教程的八叉树是基本的教程,帮助你理解空间分隔的思想。代码中还有一点小缺陷,例如,改变一个不存在的modelID的模型的世界矩阵会引发错误。 DeleteDrawableModel方法也可以加以改进,因为整个八叉树会一直搜索直到最后一个对象。更好的方法是将所有模型的位置存储在一个集合中,这样八叉树可以以一个更有效率的方式找到模型。而且,ID已经包含了应该朝哪个方向搜索的信息。 而且,这个方法让你只存储(绘制)包含effect的模型,因为DrawableModel的Draw方法是非常具体的。更普遍的方法是定义一个虚拟DrawableObj类,这个类可以存储在八叉树中,其他类可以从这个类继承。 再次说,本教程是帮助你理解八叉树的概念的,只需添加更严格的功能,这个结构己经足以介绍八叉树了。 对象剔除的知识非常广泛。本教程展示了一个基本例子,因为这个例子只剔除不在相机视野中的对象,所以方法不是很难。更先进的解决方案是闭塞剔除(occlusion culling),一个3D空间分隔成对应的3D空间本身。例如,将物体放在房子内,会使用房子对应的空间而不是对称的立方体。这些房间彼此以房门连接。通过这种方式,你可以检查房间是否在视野内或者检查墙或房门是否阻挡了视线。

在3D世界中创建不同的相机模式——天空盒

clock 十月 23, 2010 09:28 by author alex
2.8 天空盒 问题 你想在游戏中中添加一个场景,而不是单一颜色的背景。 解决方案 你用不着将物体充满场景的每个部分,只需简单地绘制一个很大的立方体,在立方体内部的六个面上贴上风景或房子内部的纹理,然后把相机放在立方体内部。 工作原理 有几种方法可以将一个天空盒放置在场景中,最简单的方法是导入一个模型文件,这个文件拥有自己的纹理和effect。如果你想获得完全控制权,你也可以自己定义一个立方体。我还展示了一个HLSL示例,介绍如何使用单张TextureCube纹理实现天空盒的方法,而这个纹理使用了HLSL的texCUBE内置函数。 以上两个技术相同点是: 立方体必须总是绘制在相机周围,而相机应该总处于立方体中心。只有这样才能让玩家在移动相机时,保持相机和立方体之间的距离不变,使场景给人一种在无穷远处的印象。 当绘制天空盒时你应该让Z缓冲不可写,这样就无需缩放天空盒以适应场景。但注意不要忘记在绘制完天空盒后重新使Z缓冲可写。 从文件载入天空盒 一个方法是从一个模型文件载入天空盒。你可以使用来自于DirectX SDK的例子,这个例子包含了.x文件和对应的六张纹理。将它们复制到你的项目中,最后在项目中添加skyboxModel和skyboxTransforms变量: Model skyboxModel; Matrix[] skyboxTransforms; 在LoadContents方法中加载模型: skyboxModel = content.Load<Model>("skybox"); skyboxTransforms = new Matrix[skyboxModel.Bones.Count]; 然后就像对待普通模型一样绘制天空盒: skyboxModel.CopyAbsoluteBoneTransformsTo(skyboxTransforms); foreach (ModelMesh mesh in skyboxModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) { effect.World = skyboxTransforms[mesh.ParentBone.Index]; effect.View = fpsCam.ViewMatrix; effect.Projection = fpsCam.ProjectionMatrix; } mesh.Draw(); } 这样只是简单地将天空盒作为一个盒子绘制,但是,当你移动相机时,你会更加靠近天空盒,纹理会拉近,会使你感到远处的天空是假的。要避免这种情况发生,天空盒必须看起来距离相机无穷远,这样当相机移动时,天空盒的纹理不会变大。 要做到这一点需要保证相机总在天空盒的中心、换句话说,你要通过将天空盒移动到相机位置而使相机总保持处在天空盒中心,你需要这样设置世界矩阵: effect.World = skyboxTransforms[mesh.ParentBone.Index] *Matrix.CreateTranslation(fpsCam.Position); 现在,当你运行代码,无论你如何移动相机天空盒看起来总停在同样的位置,给人在无穷远处的感觉。 下一个可能会遇到的问题是你可以清晰地看到立方体的边缘,这是因为XNA对靠近边缘的纹理的采样方式,将纹理寻址方式改成Clamp(见教程5-2),可以让立方体边缘的颜色截取至纹理边界的颜色 device.SamplerStates[0].AddressU = TextureAddressMode.Clamp; device.SamplerStates[0].AddressV = TextureAddressMode.Clamp; 运行后可以发现立方体边缘变得平滑得多了。 在场景中绘制物体 如果立方体中没有物体,你的场景看起来不会很漂亮。当你将物体放置在场景中时,你还要考虑另外一些问题:天空盒的大小。如何让你的天空盒大到能包容场景中的所有物体又不会因为过大而被投影矩阵裁剪掉? 其实大小并不重要,你可以用任意大小绘制天空盒,在在绘制其他物体之前必须保证XNA不能写入深度缓冲。通过这种方式,任何在天空盒之后绘制的物体就想没有天空盒一样被绘制到场景中。 下面是工作原理:无论你是绘制一个最低点是(-1,-1,-1)最高点是(1,1,1)的盒子还是最低点是(-100,-100,-100) 最高点是(100,100,100)的盒子,如果你将相机放置在盒子中心,它们看起来是一样的。所以,你可以放心地以模型原始大小绘制天空盒。但如果盒子太小,会把场景中的物体隐藏到它的后面,这就是为什么你需要在绘制天空盒时关闭写入深度缓冲的原因。通过这种方式,当天空盒作为第一个物体绘制后,深度缓冲仍是空的!所有在此之后绘制的物体并不知道天空盒已近被绘制了,对应物体的像素会使用物体的颜色。关于Z缓冲更多知识请见教程2-1。 所以,你需要在绘制天空盒前加入以下代码: device.RenderState.DepthBufferWriteEnable = false; 确保在绘制完天空盒后打开Z缓冲,否则你的模型会混在一起: device.RenderState.DepthBufferWriteEnable = true; 手动定义天空盒 从一个文件载入天空盒不够灵活。例如,前面一个例子中的天空盒使用了六张纹理使模型要分成六个不同的面,而这六个面都要调用DrawPrimitives 方法只是为了绘制两个三角形。少量的三角形对DrawPrimitives 的大量调用会拖慢XNA程序的速度(见教程3-4和3-11)。 虽然六次调用影响不大,但接下来我们还是想自己建立天空盒的顶点,整个天空盒使用一张纹理只需调用DrawPrimitives一次。我们使用TextureCube代替Texture2D存储天空盒纹理。TextureCube也是一张2D图片,由天空盒的六个面的图像组成,见图2-9。 图2-9 天空盒的六个面组成一张图像 在项目中添加TextureCube,Effect和VertexBuffer变量: VertexBuffer skyboxVertexBuffer; TextureCube skyboxTexture; Effect skyboxEffect; 然后在LoadContent 方法中载入effect和纹理: effect = content.Load<Effect>("skyboxsample"); skyboxTexture = content.Load<TextureCube>("skyboxtexture"); 你将在后面创建skyboxsample effect 文件。 提示:你可以使用DirectX SDK 中的DirectX Texture tool创建自己的TextureCube图像。安装完SDK后,你会在开始菜单中找到这个工具的快捷方式。只需选择File→New并选择Cubemap Texture,然后从View→Cube Map Face菜单中选择要应用的面。你也可以实时定义面的内容,具体信息可见教程3-7。 通过手动定义每个顶点,绘制天空盒只需调用DrawPrimitives一次。当然,顶点需要包含位置信息,而且因为要在三角形上添加纹理,还需要在顶点中添加纹理坐标信息。但是,因为你使用HLSL内置的texCUBE函数从TextureCube中采样颜色数据,所以不需要纹理坐标数据,你会在下面学习更多texCUBE的细节知识。 但XNA Framework无法处理只包含位置信息的顶点,你当然可以使用一个复杂结构,比如VertexPositionColor而将颜色数据保持为空值,但这样做仍会将颜色数据发送到显卡,会浪费带宽。要使程序保持清晰,你可以创建最简单的顶点格式,只包含位置数据。可见教程5-14学习如何自定义顶点格式: public struct VertexPosition { public Vector3 Position; public VertexPosition(Vector3 position) { this.Position = position; } public static readonly VertexElement[] VertexElements = { new VertexElement( 0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0 ) }; public static readonly int SizeInBytes = sizeof(float) * 3; } 这个结构定义了一个自定义的顶点格式,只包含一个Vector3存储位置信息。当定义天空盒顶点时,必须要考虑三角形顶点的环绕顺序,否则某些面就会被剔除。要学习更多剔除(culling)的知识可见教程5-6。当相机在天空盒中时,每个面都不能被剔除,所以所有三角形都应该以顺时针的顺序排列,这样才能被相机看到。为了让事情变得简单点,我给立方体的八个角都起了一个相对于相机而言的名称: Vector3 forwardBottomLeft = new Vector3(-1, -1, -1); Vector3 forwardBottomRight = new Vector3(1, -1, -1); Vector3 forwardUpperLeft = new Vector3(-1, 1, -1); Vector3 forwardUpperRight = new Vector3(1, 1, -1); Vector3 backBottomLeft = new Vector3(-1, -1, 1); Vector3 backBottomRight = new Vector3(1, -1, 1); Vector3 backUpperLeft = new Vector3(-1, 1, 1); Vector3 backUpperRight = new Vector3(1, 1, 1); 接着开始使用前面定义的Vector3定义三角形。立方体有六个面,每个面由两个三角形组成,所以你需要定义36个顶点。定义完所有顶点后,要将它们放在VertexBuffer(可见教程5-4)中发送到显存中: VertexPosition[] vertices = new VertexPosition[36]; int i = 0; //face in front of the camera vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(forwardBottomRight); //face to the right of the camera vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(backBottomRight); //face behind the camera vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backBottomRight); vertices[i++] = new VertexPosition(backUpperRight); //face to the left of the camera vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(forwardBottomLeft); //face above the camera vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardUpperRight); //face under the camera vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(backBottomRight); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(backBottomRight); skyboxVertexBuffer = new VertexBuffer(device, vertices.Length * VertexPosition.SizeInBytes, BufferUsage.WriteOnly); skyboxVertexBuffer.SetData<ertexPosition>(vertices); HLSL 定义了顶点后,就可以处理HLSL了。TextureCube只是一张2D图像,所以你可以使用普通的texture和sampler定义: Texture xCubeTexture; sampler CubeTextureSampler = sampler_state { texture = <xCubeTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = clamp; AddressV = clamp; }; 这个纹理将在pixel shader中由texCUBE进行采样,texCUBE接受两个参数。第一个参数是纹理对应的采样器,第二个参数指定从哪里对纹理进行采样。texCUBE沿着从立方体中心指向3D位置的方向获取颜色,而不是通过2D纹理坐标获取颜色,接着texCUBE计算对应的纹理坐标。 这样做有几个优点。因为相机总在立方体中心,这个方向就是像素的3D位置的所需颜色!而且,因为你处理的是一个方向,所以无需进行缩放,例如方向(1,3,-2)和方向(10,30,-20)是相同的。 这样,对于每个立方体的顶点,vertex shader需要将3D位置传送到pixel shader(接着转换为2D屏幕坐标): //Technique: Skybox struct SkyBoxVertexToPixel { float4 Position : POSITION; float3 Pos3D : TEXCOORD0; }; SkyBoxVertexToPixel SkyBoxVS( float4 inPos : POSITION) { SkyBoxVertexToPixel Output = (SkyBoxVertexToPixel)0; float4x4 preViewProjection = mul (xView, xProjection); float4x4 preWorldViewProjection = mul (xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.Pos3D = inPos; return Output; } 你可以看到vertex shader只需每个顶点的位置,这个位置是由你定义在XNA代码中的顶点提供的。将这个位置乘以世界矩阵、观察矩阵和投影矩阵的组合就可以获得2D屏幕坐标。顶点的原始位置在Output.Pos3D中被传递到pixel shader,在texCUBE中需要这个原始位置。 注意:在大多数情况中你需要原始3D位置,但这次3D位置没有乘以世界矩阵,因为你需要获取从立方体中心指向顶点的方向,这个方向等于“顶点位置减去中心位置”。根据你定义立方体顶点的方式,在原始空间(又叫做模型空间)中中心位置是(0,0,0),所以这个方向就是顶点的位置。 在pixel shader中只获取了这个方向,并找到对应的颜色,最终传递到帧缓冲: struct SkyBoxPixelToFrame { float4 Color : COLOR0; }; SkyBoxPixelToFrame SkyBoxPS(SkyBoxVertexToPixel PSIn) { SkyBoxPixelToFrame Output = (SkyBoxPixelToFrame)0; Output.Color = texCUBE(CubeTextureSampler, PSIn.Pos3D); return Output; } 既然你已经计算了像素和立方体中心之间的方向,接着可以将它直接传递到texCUBE作为第二个参数,而texCUBE从纹理中采样对应的颜色,最后这个颜色被传递到帧缓冲。 XNA代码 将这个结构放在命名空间顶部: public struct VertexPosition { public Vector3 Position; public VertexPosition(Vector3 position) { this.Position = position; } public static readonly VertexElement[] VertexElements = { new VertexElement( 0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0 ) }; public static readonly int SizeInBytes = sizeof(float) * 3; } 这个方法定义了36个顶点: private void CreateSkyboxVertexBuffer() { Vector3 forwardBottomLeft = new Vector3(-1, -1, -1); Vector3 forwardBottomRight = new Vector3(1, -1, -1); Vector3 forwardUpperLeft = new Vector3(-1, 1, -1); Vector3 forwardUpperRight = new Vector3(1, 1, -1); Vector3 backBottomLeft = new Vector3(-1, -1, 1); Vector3 backBottomRight = new Vector3(1, -1, 1); Vector3 backUpperLeft = new Vector3(-1, 1, 1); Vector3 backUpperRight = new Vector3(1, 1, 1); VertexPosition[] vertices = new VertexPosition[36]; int i = 0; //face in front of the camera vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(forwardBottomRight); //face to the right of the camera vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(forwardUpperRight); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(backBottomRight); //face behind the camera vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backBottomRight); vertices[i++] = new VertexPosition(backUpperRight); //face to the left of the camera vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(forwardBottomLeft); //face above the camera vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backUpperLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardUpperLeft); vertices[i++] = new VertexPosition(backUpperRight); vertices[i++] = new VertexPosition(forwardUpperRight); //face under the camera vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(backBottomRight); vertices[i++] = new VertexPosition(backBottomLeft); vertices[i++] = new VertexPosition(forwardBottomLeft); vertices[i++] = new VertexPosition(forwardBottomRight); vertices[i++] = new VertexPosition(backBottomRight); skyboxVertexBuffer = new VertexBuffer(device, vertices.Length * VertexPosition.SizeInBytes, BufferUsage.WriteOnly); skyboxVertexBuffer.SetData<ertexPosition>(vertices); 在Draw 方法中添加以下代码,使用自定义effect 绘制三角形: graphics.GraphicsDevice.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0); device.RenderState.DepthBufferWriteEnable = false; skyboxEffect.CurrentTechnique = skyboxEffect.Techniques["SkyBox"]; skyboxEffect.Parameters["xWorld"].SetValue(Matrix.CreateTranslation(fpsCam.Position)); skyboxEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix); skyboxEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix); skyboxEffect.Parameters["xCubeTexture"].SetValue(skyboxTexture); skyboxEffect.Begin(); foreach (EffectPass pass in skyboxEffect.CurrentTechnique.Passes) { pass.Begin(); device.VertexDeclaration = new VertexDeclaration(device, VertexPosition.VertexElements); device.Vertices[0].SetSource(skyboxVertexBuffer, 0, VertexPosition.SizeInBytes); device.DrawPrimitives(PrimitiveType.TriangleList, 0, 12); pass.End(); } skyboxEffect.End(); device.RenderState.DepthBufferWriteEnable = true; HLSL代码 下面是HLSL代码: float4x4 xWorld; float4x4 xView; float4x4 xProjection; Texture xCubeTexture; sampler CubeTextureSampler = sampler_state { texture = <xCubeTexture>; magfilter = LINEAR; minfilter = LINEAR; mipfilter=LINEAR; AddressU = clamp; AddressV = clamp; }; //Technique: Skybox struct SkyBoxVertexToPixel { float4 Position : POSITION; float3 Pos3D : TEXCOORD0; }; SkyBoxVertexToPixel SkyBoxVS( float4 inPos : POSITION) { SkyBoxVertexToPixel Output = (SkyBoxVertexToPixel)0; float4x4 preViewProjection = mul (xView, xProjection); float4x4 preWorldViewProjection = mul (xWorld, preViewProjection); Output.Position = mul(inPos, preWorldViewProjection); Output.Pos3D = inPos; return Output; } struct SkyBoxPixelToFrame { float4 Color : COLOR0; }; SkyBoxPixelToFrame SkyBoxPS(SkyBoxVertexToPixel PSIn) { SkyBoxPixelToFrame Output = (SkyBoxPixelToFrame)0; Output.Color = texCUBE(CubeTextureSampler, PSIn.Pos3D); return Output; } technique SkyBox { pass Pass0 { VertexShader = compile vs_1_1 SkyBoxVS(); PixelShader = compile ps_1_1 SkyBoxPS(); } }

友情链接赞助