毛发绘制(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的主要思路可由下图表示。

shell rendering

如你所见,有一些水平切片(horizontal slices)穿过一组毛发。每个切片本身并无法表示毛发:它只是一个其上有很多点的多边形。但是当一个叠加在一个之上的切片数量足够多时,就会出现毛发的效果了。这就好像通过绘制距离很近的大量点就可以绘制一条直线,绘制毛发的道理是类似的,但我们需要考虑三个问题:

接下来,我们将一步步地实现毛发的绘制,首先从生产毛发纹理开始。 

生成毛发贴图(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;

改进的结果

如你所见,显示效果好多了,带纹理的毛发效果也得到了改进,如图所示。

带纹理的改进结果1

带纹理的改进结果2

改进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 13:29:13  阅读次数:10060

2006 - 2024,推荐分辨率1024*768以上,推荐浏览器Chrome、Edge等现代浏览器,截止2021年12月5日的访问次数:1872万9823 站长邮箱

沪ICP备18037240号-1

沪公网安备 31011002002865号