跳到主要内容

Unity Shader - 毛发渲染,飘逸的毛发

本文介绍了一种在 Unity 中实现真实感动物毛发的技术。通过多层渲染、顶点偏移、噪音贴图和环境光照处理等手段,使得渲染出的毛发层次丰富且自然。此外,还介绍了如何使用 C# 代码控制毛发的动态效果,实现缓慢飘动的视觉表现。


毛发渲染效果预览

上图:在 Unity 中实际渲染出的毛发效果预览。

渲染原理

整体思路可以分为三步:

第一步:分层 Layer,渲染不同长度的毛发

  • 把毛发拆成多层(Layer)渲染,不同层表示不同长度的毛发;层越多,细节越丰富。
  • 因为毛发有长短,在上层没有毛发的部分将 alpha 设为 0,不显示。
  • 多个 Layer 可以使用 Shader 的多个 Pass 进行绘制:每一个 Pass 即表示一层。
  • 当渲染每一层时,使用法线把顶点位置挤出模型表面,模拟毛发竖起的体积效果。

第二步:使用噪声贴图随机毛发分布与长度

  • 根据噪音贴图,随机控制每个位置毛发的显示与长度,通过 alpha 变化实现。
  • 不同层的毛发,其 UV 位置偏移也应该不同,这样才显得更自然;
    否则 UV 一直不变,毛发方向看起来就像刺猬一样整齐。
  • 毛发在不同层的偏移,也可以受到外力的影响,比如风等,所以可以额外设置一个偏移量。

第三步:处理光照,设置颜色

  • 根据环境光、反射、高光、漫反射等,处理毛发的光照。
  • 可以为毛根、毛尖、边缘光等设计不同的颜色,用以表现毛发的层次感和柔软度。

实现步骤

1. 多 Pass 渲染(按层渲染毛发)

对于皮毛来说,每一层的渲染方式是一致的,所以可以把公共逻辑提炼到一个 .cginc 文件中,在每个 Pass 中引用,避免重复代码。

不同的层次需要传入参数控制:
使用 (1 / 总层数) 作为每一层的长度步进,配合 FURSTEP 来控制当前层对应的“毛发深度”。

// 表面皮肤渲染
Pass
{
    CGPROGRAM
                
    #pragma vertex vert_surface    // 顶点着色器,对应 FurHelper.cginc 中的方法
    #pragma fragment frag_surface  // 片元着色器,对应 FurHelper.cginc 中的方法
    #define FURSTEP 0.0
    #include "FurHelper.cginc"     // 引用的 shader 辅助文件
                
    ENDCG
}

// 毛发渲染
Pass
{
    CGPROGRAM
                
    #pragma vertex vert_base       // 顶点着色器
    #pragma fragment frag_base     // 片元着色器
    #define FURSTEP 0.05           // 当前这一层的皮毛深度
    #include "FurHelper.cginc"
                
    ENDCG
}

实际使用中可以通过宏或循环自动生成多层 Pass,这里只是概念示例。


2. 顶点外扩:实现多层次偏移

思路:在顶点的法线方向,向外扩展一定的距离,所以在顶点着色器中处理。

v2f vert (a2v v)
{
    v2f o;

    // 顶点沿法线方向外扩,形成毛发层
    float3 offsetVertex = v.vertex.xyz + v.normal * _LayerOffset * _FurLength;

    // 顶点受力偏移(例如重力或风的方向偏移,让毛发向下垂)
    offsetVertex += mul(unity_WorldToObject, _FurOffset);

    // 后续再把 offsetVertex 写回 o 进行常规的 MVP 变换
    // 这里省略了完整的变换代码,仅展示核心思路
    // ...

    return o;
}
  • _LayerOffset:当前 Shell 所处的层偏移(0~1)。
  • _FurLength:毛发最大长度参数,用来限制最外层的扩展距离。
  • _FurOffset:受力偏移(例如重力、风向),使毛发整体有一个方向上的偏移,看起来更自然。

3. 使用噪音贴图控制透明度

思路:利用第二套 UV(例如 uv2)采样噪音贴图,根据噪音值的随机性控制毛发透明度。

fixed3 noise = tex2D(_FurTex, i.uv.zw).rgb;
fixed alpha = saturate(noise - (_LayerOffset * _LayerOffset + _LayerOffset * _FurDensity));
  • _FurTex:噪声/毛发分布纹理。
  • i.uv.zw:通常为 uv2,专门用于采样噪声纹理。
  • _FurDensity:毛发密集度参数,影响透明度计算,让毛发看起来更稀疏或更浓密。

不同层可以在 UV 上增加轻微偏移,避免各层噪声完全重合,提升自然感。


4. 参数提炼与优化

可以把常用参数提炼出来,方便美术和程序在 Inspector 中调节:

  • 毛发长度参数:限制最外层的扩展长度(_FurLength)。
  • 毛发层数:Shell 的数量(LayerCount)。
  • 颜色参数
    • 整体毛发颜色(_Color / FurColor
    • 根部颜色(_RootColor / FurRootColor
    • 边缘光颜色(_RimColor / FurRimColor
    • 高光颜色(_Specular / FurSpecularColor
  • 毛发密集参数:影响噪声贴图采样、控制 UV 的 Tiling(_FurDensity)。
  • 偏移参数:例如长毛宠物的毛会自然下垂,可用 FurForceFurTenacity 等参数控制。

5. 使用 C# 控制毛发层与动态移动

为了实现更飘逸的动态效果,可以让 Shader 专注于“单层渲染”,而用 C# 来生成多层 Shell,并在 Update 中用 Lerp 让它们缓慢跟随主体移动。

思路:

  1. Shader 负责渲染“一层毛发”。
  2. C# 代码克隆多个带有同一 Shader 的 GameObject,根据层索引设置不同的 _LayerOffset_FurOffset
  3. Update 中通过 Lerp 控制每一层渐进跟随目标位置和旋转,实现柔和的飘动感。

生成 Shell:克隆对象 & 赋值材质

// 生成:克隆目标,赋值材质与 shader
void CreateShell()
{
    CheckParent();

    layers = new GameObject[LayerCount];
    float furOffset = 1.0f / LayerCount;

    for (int i = 0; i < LayerCount; i++)
    {
        // 复制渲染的原模型
        GameObject layer = Instantiate(Target.gameObject,
                                       Target.transform.position,
                                       Target.transform.rotation);

        layer.transform.parent = _parent;

        // 这里只用一张材质
        layer.GetComponent<Renderer>().sharedMaterials = new Material[1];
        layer.GetComponent<Renderer>().sharedMaterial = ResetSharedMaterials(i, furOffset);

        layers[i] = layer;
    }
}

为每一层设置 Shader 参数

// Shader 与毛发参数赋值
Material ResetSharedMaterials(int index, float furOffset)
{
    Material special = new Material(ShellShader);

    if (special != null)
    {
        special.SetTexture("_MainTex", Target.sharedMaterial.GetTexture("_MainTex"));

        special.SetColor("_Color",      FurColor);
        special.SetColor("_RootColor",  FurRootColor);
        special.SetColor("_RimColor",   FurRimColor);
        special.SetColor("_Specular",   FurSpecularColor);

        special.SetFloat("_Shininess",  Shininess);
        special.SetFloat("_RimPower",   RimPower);
        special.SetFloat("_FurShadow",  FurShadow);

        special.SetTexture("_FurTex",   FurPattern);
        special.SetFloat("_FurLength",  FurLength);
        special.SetFloat("_FurDensity", FurDensity);
        special.SetFloat("_FurThinness",FurThinness);

        // 不同 Shell 的层偏移参数
        special.SetFloat("_LayerOffset", index * furOffset);

        // 计算受力、层数和“韧性”共同影响的 Shell 偏移
        special.SetVector("_FurOffset",
            FurForce * Mathf.Pow(index * furOffset, FurTenacity));

        // 由于使用了半透明并写入深度,为防止被深度剔除,手动调整渲染队列
        special.renderQueue = 3000 + index;
    }

    return special;
}

Shell 的缓慢跟随(飘逸移动)

// 在 Update 中控制每一层缓慢移动,营造“飘逸感”
void UpdateShellTrans()
{
    if (layers == null || layers.Length == 0)
        return;

    for (int i = 0; i < layers.Length; i++)
    {
        // 位置和旋转 Lerp 到目标模型
        Transform t = layers[i].gameObject.transform;

        t.position = Vector3.Lerp(
            t.position,
            Target.transform.position,
            lerpSpeed * Time.deltaTime * 20f
        );

        t.rotation = Quaternion.Lerp(
            t.rotation,
            Target.transform.rotation,
            lerpSpeed * Time.deltaTime * 10f
        );
    }
}

通过以上逻辑,Shell 会在动画过程中略微“追随”主体,而不是 100% 绑死,从而看起来更柔和、更有动态感。


参考资料