毛发绘制(Fur Rendering)
原文地址:http://www.sgtconker.com/2009/10/article-fur-rendering/。
这篇文件介绍的是如何在XNA中绘制毛发。首先,你将对绘制毛发的技术有个大概的了解,然后再对它进行改进,最后将这个shader施加到一个模型上。
绘制毛发
要绘制毛发,可以有几个不同的方法。最直接的方法就是使用一个或两个多边形对每根毛发建模。使用这个方法,你可以实现每根毛发的动画,但建模和绘制的开销很大。另一个方法是创建一些多边形,在这些多边形上绘制包含毛发的纹理,这些纹理有透明的背景(一个多边形包含一组毛发的竖直切片)。这个方法实现动画也很简单,但是当使用alpha混合时会出现一些问题(因为你需要对多边形进行排序) ,而且从上方观察时,毛发会显得比较稀疏(译者注:要绘制草地通常会使用这种方法)。本示例要实现的方法叫做shell rendering,这个方法需要通过绘制一些穿过毛发的水平切片创建毛发。与前面的方法相反,使用这个方法,当从较低角度观察(从旁边看)毛发时会出现一些问题。但是,当将这个效果施加到一个模型时而不是一个平面时,这个显示错误是不可见的,我们可以不予考虑。还有一个方法是组合shell rendering和竖直切片,这个方法叫做shells and fins,但本教程我们只关注shells。
Rendering with shells
shell rendering的主要思路可由下图表示。
如你所见,有一些水平切片(horizontal slices)穿过一组毛发。每个切片本身并无法表示毛发:它只是一个其上有很多点的多边形。但是当一个叠加在一个之上的切片数量足够多时,就会出现毛发的效果了。这就好像通过绘制距离很近的大量点就可以绘制一条直线,绘制毛发的道理是类似的,但我们需要考虑三个问题:
- 毛发所处位置的信息。当绘制每个切片时,我们需要知道哪些像素是可见的(即属于直立的毛发的),哪些像素是透明的。我们将这个信息存储在一张纹理中。这个纹理中不透明的像素表示一个直立的毛发。当创建这个纹理时,我们会在其上随机放置不透明的像素,像素的数量是由一个表示毛发密度的值决定的。
- 切片的位置。如果只是制作一个简单的2D水平面上的毛发,切片的位置只是简单的沿着Up轴方向。但是,我们想在任意模型表面生成毛发,因此最好的方法就是让每个切片基于多边形的法线设置。事实上,要创建切片,我们只需绘制每个多边形几次,在每次绘制中,基于多边形的法线在一个很小的距离上显示多边形。
- 每根毛发的颜色。一开始,我们只是简单地从密度贴图读取颜色,但是到后面,我们将会从一个独立的贴图中读取颜色,这样可以实现一个漂亮的毛发效果。
接下来,我们将一步步地实现毛发的绘制,首先从生产毛发纹理开始。
生成毛发贴图(fur map)
如前所述,毛发贴图用来绘制毛皮的每个切片,它包含透明像素用于没有毛发的区域,不透明像素用于直立的毛发。我们使用密度(density)计算这些像素的数量,然后将它们随机放置在表面纹理上,代码如下:
////// 用于生成毛发绘制的纹理 /// /// 最终的毛发纹理 /// 位于[0..1]区间的毛发密度 private void FillFurTexture(Texture2D furTexture, float density) { // 读取纹理的长和宽 int width = furTexture.Width; int height = furTexture.Height; int totalPixels = width * height; //保存像素的数组 Color[] colors; colors = new Color[totalPixels]; // 随机数生成器 Random rand = new Random(); // 将像素的颜色初始化为transparent black for (int i = 0; i < totalPixels; i++) colors[i] = Color.TransparentBlack; // 计算不透明像素的数量,即直立毛发的数量 int nrStrands = (int)(density * totalPixels); // 使用不透明像素填充纹理 for (int i = 0; i < nrStrands; i++) { int x, y; // 获取随机位置 x = rand.Next(height); y = rand.Next(width); // 施加颜色(alpha值为255) colors[x * width + y] = Color.Gold; } // 将像素施加到纹理上。 furTexture.SetData(colors); }
有了毛发纹理,我们首先将在一个简单的多边形上绘制毛发。为了简单起见,我们只是使用一个顶点数组,用DrawUserPrimitives进行绘制,在教程的最后,我们会使用Model类。
生成几何体
生成几何体的方法只是简单地创建了两个三角形,将它放置在XY平面,-Z轴为法线。
VertexPositionNormalTexture[] vertices; private void GenerateGeometry() { vertices = new VertexPositionNormalTexture[6]; vertices[0] = new VertexPositionNormalTexture(new Vector3(-10,0,0), -Vector3.UnitZ, new Vector2(0,0)); vertices[1] = new VertexPositionNormalTexture(new Vector3(10,20,0), -Vector3.UnitZ, new Vector2(1,1)); vertices[2] = new VertexPositionNormalTexture(new Vector3(-10, 20, 0), -Vector3.UnitZ, new Vector2(0, 1)); vertices[3] = vertices[0]; vertices[4] = new VertexPositionNormalTexture(new Vector3(10, 0, 0),-Vector3.UnitZ, new Vector2(1, 0)); vertices[5] = vertices[1]; }
绘制shells
我们需要绘制一定数量的切片,每个切片距离前一个切片有一小段距离,在CPU上生成这个偏移是一个耗时的工作,既然我们需要访问顶点着色器,所以将GPU可以很容易实现的工作放在CPU上处理是不合适的。要实现所有层,我们需要发送相同的几何数据多次。我们还要告知shader当前绘制的是哪个切片,以及离开几何体表面的最大偏移。使用这个信息,顶点着色器会调整每个顶点的位置,将当前层稍稍沿着法线方向移动。
现在你已经有了几何体和毛发纹理,下一步是编写shader处理毛发的绘制。你需要在Content项目中创建一个新shader,在shader中我们需要一些参数,除了World,View和Projection矩阵,还需要一个参数控制毛发长度,名为MaxHairLength,一个参数告知我们当前处理的是哪一个切片。为了让处理更加灵活,我们将参数CurrentLayer设置到[0..1]区间,0 表示当前切片是离真实表面最近的一个,1表示最远的一个。位于中间的切片会映射到0至1之间的值。将这个值乘以毛发长度就会获得当前时刻正在处理的切片的偏移量。最后,我们需要从毛发纹理中读取数据,所以需要一个参数和一个采样器。
float4x4 World; float4x4 View; float4x4 Projection; float CurrentLayer; //位于0和1之间的值 float MaxHairLength; //最大毛发长度 texture FurTexture; sampler FurSampler = sampler_state { Texture = (FurTexture); MinFilter = Point; MagFilter = Point; MipFilter = Point; AddressU = Wrap; AddressV = Wrap; };
每个顶点需要位置、法线和纹理坐标的数据,这些数据是由程序发送的,所以需要声明一个顶点输入结构。顶点着色器的输出包含了最终位置和纹理坐标。在后面的教程中,你还要添加光照,因此法线也需要输出,但是现在,我们暂时不输出法线。下面是两个顶点着色器结构:
struct VertexShaderInput { float3 Position : POSITION0; float3 Normal : NORMAL0; float2 TexCoord : TEXCOORD0; }; struct VertexShaderOutput { float4 Position : POSITION0; float2 TexCoord : TEXCOORD0; };
现在处理shader的主要部分。在顶点着色器中,我们需要生成顶点的新位置。要获取这个新位置,我们需要计算当前切片的偏移,并将顶点沿着法线方向移动这个偏移值,下面是代码:
float3 pos = input.Position + input.Normal * MaxHairLength * CurrentLayer;
上面的代码获得了顶点的最终位置。顶点着色器的其余代码完成的是常规的工作:使用三个矩阵转换顶点位置,将纹理坐标传递到像素着色器中。最终的顶点着色器代码如下:
VertexShaderOutput FurVertexShader(VertexShaderInput input) { VertexShaderOutput output; float3 pos; pos = input.Position + input.Normal * MaxHairLength * CurrentLayer; float4 worldPosition = mul(float4(pos,1), World); float4 viewPosition = mul(worldPosition, View); output.Position = mul(viewPosition, Projection); output.TexCoord = input.TexCoord; return output; }
像素着色器只是简单地读取毛发纹理并输出颜色。因为我们设置aplha为0的区域表示没有毛发,所以需要使用alpha混合让这些区域透明,而有毛发的区域保持不透明。要指定alpha混合,既可以在程序中进行,也可以在techniques声明和passes中进行,本例中我们选择了后者。
float4 FurPixelShader(VertexShaderOutput input) : COLOR0 { return tex2D(FurSampler, input.TexCoord); } technique Fur { pass Pass1 { AlphaBlendEnable = true; SrcBlend = SRCALPHA; DestBlend = INVSRCALPHA; CullMode = None; VertexShader = compile vs_2_0 FurVertexShader(); PixelShader = compile ps_2_0 FurPixelShader(); } }
整合在一起:绘制几何体
为了让代码清晰,我们创建了一个方法用于绘制几何体。这个方法需要设置顶点声明并调用GraphicsDevice.DrawUserPrimitives。
private void DrawGeometry() { using (VertexDeclaration vdecl = new VertexDeclaration(GraphicsDevice, VertexPositionNormalTexture.VertexElements)) { GraphicsDevice.VertexDeclaration = vdecl; GraphicsDevice.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2); } }
现在我们需要在Draw方法中写些代码用于绘制几何体和毛发层。但在这之前,我们先需要定义一些变量并在LoadContent()方法中进行初始化。还要将Camera.cs文件添加到项目中并对相机进行初始化。
[...] using XNASimpleCamera; //namespace of Camera.cs [...] public class Game1 : Microsoft.Xna.Framework.Game { [...] // 相机 Camera camera; // 包含毛发数据的纹理 Texture2D furTexture; // 用于fur shader的Effect Effect furEffect; // 毛发层数量 int nrOfLayers = 40; // 毛发的最大长度 float maxHairLength = 2.0f; // 毛发密度 float density = 0.2f; protected override void Initialize() { // 初始化相机 camera = new Camera(this); Components.Add(camera); base.Initialize(); } protected override void LoadContent() { [...] // 生成几何体 GenerateGeometry(); // 加载effect furEffect = Content.Load(“FurEffect”); // 创建纹理 furTexture = new Texture2D(GraphicsDevice, 256, 256, 1, TextureUsage.None, SurfaceFormat.Color); //填充纹理 FillFurTexture(furTexture, density); } }
你可以使用一些参数改变毛发的外观和行为。numberOfLayers控制数量和性能,这个值很大时虽然质量好,但性能不佳,你需要根据自己的程序进行调整。maxHairLength和density可以很容易地改变毛发的外观。
现在需要在Draw()方法中设置effect的参数。之后,我们绘制几何体nrOfLayers次,并在每次绘制前设置shader参数CurrentLayer。
protected override void Draw(GameTime gameTime) { graphics.GraphicsDevice.Clear(Color.CornflowerBlue); furEffect.Parameters[“World”].SetValue(Matrix.CreateTranslation(0,-10,0)); furEffect.Parameters[“View”].SetValue(camera.View); furEffect.Parameters[“Projection”].SetValue(camera.Projection); furEffect.Parameters[“MaxHairLength”].SetValue(maxHairLength); furEffect.Parameters[“FurTexture”].SetValue(furTexture); furEffect.Begin(); for (int i = 0; i < nrOfLayers; i++) { furEffect.Parameters[“CurrentLayer”].SetValue((float)i/nrOfLayers); furEffect.CommitChanges(); furEffect.CurrentTechnique.Passes[0].Begin(); DrawGeometry(); furEffect.CurrentTechnique.Passes[0].End(); } furEffect.End(); }
运行程序后截图如下:
如你所见,已经可以显示直立的毛发了。但是还有两件事情可以加以改进。第一:在毛发底部绘制一个不透明的多边形,这样就不会透过毛发看到另一面;第二:毛发的颜色可以从一张纹理中提取。
将下列图片添加到Content项目中。
现在在Game类中添加一个变量保存这个纹理,并LoadContent()方法中加载这个纹理。
Texture2D furColorTexture; protected override void LoadContent() { [...] furColorTexture = Content.Load(“bigtiger”); }
然后,我们需要调整shader。我们需要为这个纹理添加一个新参数和采样器。在像素着色器中,我们读取颜色信息并将它与毛发纹理的alpha值组合。(注意:我们也可以将透明信息也放在颜色纹理中,但分离透明信息可以让我们比较容易地改变颜色纹理,还可以给写到毛发纹理RGB通道的参数留下空间,我们会在后面讨论这一点)
为了让最下面的层不透明,我们需要将变量CurrentLayer与0比较,如果是0,就要将alpha设置为1(不透明),否则,设置成从毛发纹理中读取的值。
texture Texture; sampler FurColorSampler = sampler_state { Texture = (Texture); MinFilter = Linear; MagFilter = Linear; MipFilter = Linear; AddressU = Wrap; AddressV = Wrap; }; [...] float4 FurPixelShader(VertexShaderOutput input) : COLOR0 { float4 furData = tex2D(FurSampler, input.TexCoord); float4 furColor = tex2D(FurColorSampler, input.TexCoord); // 最下面的层是不透明的,否则透明值取自furData的alpha通道 furColor.a = (CurrentLayer == 0) ? 1 : furData.a; return furColor; }
然后在Draw()方法中添加以下代码:
furEffect.Parameters[“Texture”].SetValue(furColorTexture);
结果如下图所示:
改进
改进1:毛发间的遮挡效果
如果我们用相同的颜色绘制整个毛发,结果不是很漂亮,看不出每个毛发的直立,它们都混在了一起,如下图所示:
我们可以使用一些虚假的阴影修复这个问题。在一丛毛发中,底部的毛发获得的光照通常要比顶部的少得多。我们可以从当前绘制的层中获取信息(通过参数CurrentLayer获取)用来添加一些阴影,处理阴影的代码位于像素着色器中。
// 基于层深度选择阴影程度。 // 我们对两个值进行插值避免底部的层漆黑一片 float shadow = lerp(0.4,1,CurrentLayer); furColor *= shadow;
如你所见,显示效果好多了,带纹理的毛发效果也得到了改进,如图所示。
改进2:高度变化
前面我们提到过使用毛发纹理的RGB通道添加一些参数用于特殊的绘制,现在我们需要让毛发有不同的高度,正是需要这些参数。
首先,我们需要修改FillFurTexture()方法。我们需要对每根毛发指定一个值,这个值表示此根毛发可见的最大层数。例如,本例中,我们将毛发数量除以层数量。假如我们有1000根毛发和10层,前100根毛发(1000/10)只有一层高,第二个100根毛发有两层高,以此类推,最后100根毛发会达到顶部,这实现了高度的线性分布。
让我们看一下代码如何。我们首先计算达到每个层的毛发数量。然后,当计算每根毛发的位置时,我们需要检查它属于哪个组,即这根毛发可以达到的最大层。接下来我们通过将这个值除以最大层数使它处于[0..1]区间内,最后将这个值设置为像素的红色通道。
private void FillFurTexture(Texture2D furTexture, float density) { [...] // 计算不透明像素的数量,即毛发的数量 int nrStrands = (int)(density * totalPixels); // 计算达到每层的毛发数量 int strandsPerLayer = nrStrands / nrOfLayers; // 使用不透明数据填充纹理 for (int i = 0; i < nrStrands; i++) { int x, y; // 随机选择纹理上的位置 x = rand.Next(height); y = rand.Next(width); // 计算最大层 int max_layer = i / strandsPerLayer; // 映射到[0..1]区间 float max_layer_n = (float)max_layer / (float)nrOfLayers; // 填充颜色(alpha值为255,即不透明) // max_layer_n需要乘以255映射到[0..255]区间 colors[x * width + y] = new Color((byte)(max_layer_n * 255), 0, 0, 255); } // 设置纹理上的像素数据 furTexture.SetData(colors); }
要使用这个数据,我们需要修改fur effect的像素着色器。在绘制一个像素前,我们需要验证储存在像素红色通道中的最大层是否小于当前层。如果是,则无需绘制当前像素。如果不是(说明我们还没有达到这个像素的最大层),就需要绘制这个像素。我们还需要将这个结果与前面我们让最低层不透明的测试组合起来。实现上述操作的两行代码如下:
float furVisibility =(CurrentLayer> furData.r) ? 0 : furData.a; furColor.a = (CurrentLayer == 0) ? 1 : furVisibility;
你可以通过下面两张图比较结果,第一张图中所有毛发使用固定高度,第二张图使用线性变化的高度。
与毛发高度相关的更进一步的改进是非线性分布的高度。要做到这点。只需添加一行代码调整max_layer_n的值,这行代码紧接在计算max_layer_n的代码之后。有好几种不同的修改方法,导致不同的结果。
max_layer_n = (float)Math.Sin(max_layer_n);
max_layer_n = (float)Math.Pow(max_layer_n,5);
max_layer_n = (float)Math.Sqrt(max_layer_n);
if ((max_layer_n> 0.05f)&& (max_layer_n<0.95f)) max_layer_n = 0.5f;
你还可以使用X和Y坐标计算高度实现更加有趣的效果。
max_layer_n = 0.2f + 0.8f * ((float)Math.Sin((double)x / height * 20) / 2.0f + 0.5f);
Vector2 dist = new Vector2((float)x / height - 0.5f, (float)y / width - 0.5f); max_layer_n = 0.4f + 0.6f *( (float)Math.Cos(dist.Length() * 50) / 2.0f + 0.5f);
你可以试着调整max_layer_n看看结果如何。
改进3:变形
要让毛发模拟变得更真实一点,我们可以让它动起来。因为毛发是由层组成的,我们没法让每根毛发独立地运动,但是我们可以立即移动全部的毛发。要实现运动,只需让每个层稍微移动一点(低一点的层这个值小,高一点的层这个值大)就可以产生变形的效果。如果每帧都改变偏位移,毛发看起来就好像在运动。本例中,我们实现的是考虑重力和一些任意力的效果。首先声明一些Vector3类型的变量,重力指向下方,现在外力为0,我们还需要一个最终的位移向量,这些变量都是在Game类中声明的。
// 运动向量 Vector3 gravity = new Vector3(0, -1.0f, 0); Vector3 forceDirection = Vector3.Zero; // 毛发的最终位移 Vector3 displacement;
在shader中也需要一个displacement参数:
float3 Displacement;
在顶点着色器中,我们将使用这个参数改变毛发顶点的位置。我们不想对所有层都施加相同的位移,这样做的话整个毛发都会一起运动了。我们使用一个介于0和1之间的因子,基于当前处理的层施加多个位移。如果你只将CurrentLayer作为因子,位移会显得不够真实。在现实世界中,毛发在风或重力影响下的运动会受到约束,这是因为毛发的不同部分具有不同的弹性系数。要模拟这种情况,我们需要使用CurrentLayer的三次方获得一个非线性的偏移,通过这个操作,偏移的表现会更加自然,代码如下:
VertexShaderOutput FurVertexShader(VertexShaderInput input) { VertexShaderOutput output; float3 pos; pos = input.Position + input.Normal * MaxHairLength * CurrentLayer; float4 worldPosition = mul(float4(pos,1), World); // 给偏移施加非线性变化,让它更像毛发 float displacementFactor = pow(CurrentLayer, 3); // 施加偏移 worldPosition.xyz +=Displacement*displacementFactor ; [...] return output; }
回到绘制代码,在Draw()方法顶部添加以下两行代码,这两行代码通过对重力和其他力求和(现在为0)计算了最终的偏移量。之后,将这个值设置为effect参数。
displacement = gravity + forceDirection; furEffect.Parameters[“Displacement”].SetValue(displacement);
结果毛发就会受重力影响而下垂:
现在在Draw()方法顶部添加一行代码,这行代码基于时间改变forceDirection,就可以实现毛发的动画效果。
forceDirection.X = (float)Math.Sin(gameTime.TotalGameTime.TotalSeconds) * 0.5f;
你可以试着改变forceDirection或gravity向量,或对模型设置旋转(furEffect.Parameters["World"])看看毛发的行为会发生什么改变。
给模型添加毛发
现在我们将要在一个模型上施加毛发shader,并且使用模型自身的纹理作为毛发的颜色。注意:这里使用的方法并不推荐使用。最好的方法是创建一个新的Content Processor,甚至是一个自定义的Model类,然后在你希望拥有毛发的模型上使用这个新的内容处理器,但是具体实现已经超出本教程的范围了。
在Content文件夹中添加一个恐龙模型,确保对应的纹理(dino.png和eyes.png)在与模型相同的目录中。
现在添加一个变量保存模型并在LoadContent()方法中进行初始化。
//dino模型 Model dino; protected override void LoadContent() { dino = Content.Load(“dino”); [...] }
我们需要两个方法,一个绘制模型,它的参数是要绘制的模型、bone集合和effect。它设置了effect的World矩阵 ,Texture参数,这样模型毛发使用的纹理就会和模型本身使用的纹理相同。
private void DrawModelGeometry(Model model, Matrix[] bones, Effect effect) { foreach (ModelMesh mesh in model.Meshes) { // 设置World矩阵 effect.Parameters[“World”].SetValue(bones[mesh.ParentBone.Index]); foreach (ModelMeshPart meshpart in mesh.MeshParts) { effect.Parameters[“Texture”].SetValue(((BasicEffect)meshpart.Effect).Texture); // 设置纹理 effect.CommitChanges(); //commit changes graphics.GraphicsDevice.VertexDeclaration = meshpart.VertexDeclaration; graphics.GraphicsDevice.Vertices[0].SetSource(mesh.VertexBuffer, meshpart.StreamOffset, meshpart.VertexStride); graphics.GraphicsDevice.Indices = mesh.IndexBuffer; // 绘制模型 graphics.GraphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, meshpart.BaseVertex, 0, meshpart.NumVertices, meshpart.StartIndex, meshpart.PrimitiveCount); } } }
第二个方法在模型上施加毛发绘制算法,它使用的代码与前面将毛发施加在一个多边形上的代码是类似的。
private void DrawFurModel(Model model) { Matrix[] bones = new Matrix[model.Bones.Count]; model.CopyAbsoluteBoneTransformsTo(bones); furEffect.Parameters[“Displacement”].SetValue(displacement); furEffect.Parameters[“MaxHairLength”].SetValue(maxHairLength); furEffect.Parameters[“FurTexture”].SetValue(furTexture); furEffect.Parameters[“View”].SetValue(camera.View); furEffect.Parameters[“Projection”].SetValue(camera.Projection); furEffect.Parameters[“MaxHairLength”].SetValue(maxHairLength); furEffect.Parameters[“FurTexture”].SetValue(furTexture); furEffect.Begin(); for (int i = 0; i < nrOfLayers; i++) { furEffect.Parameters[“CurrentLayer”].SetValue((float)i / nrOfLayers); furEffect.CommitChanges(); furEffect.CurrentTechnique.Passes[0].Begin(); //绘制当前层的几何体 DrawModelGeometry(model, bones, furEffect); furEffect.CurrentTechnique.Passes[0].End(); } furEffect.End(); }
通过在Draw方法中设置一些值,例如毛发纹理的坐标、外力和重力向量,并调用DrawFurModel(dino),获得的结果截图如下:
通过在毛发shader中添加简单的N*L光照,场景的质量变得更好。
文件下载(已下载 1469 次)
发布时间:2010/6/29 下午1:29:13 阅读次数:10603