/yx日音/hanx 发表于 2019-3-28 21:20:03

海飞丝头发的研究和实现

本帖最后由 /yx日音/hanx 于 2019-3-28 21:21 编辑

先展示成果:

https://v.qq.com/x/page/z0853wtw21a.html

很早就看了milo大佬的爱丽丝的海飞丝,那个时候完全看不懂。最近再去看了一遍,还是看不懂。打算自己研究看看。

首先查了gpugems2里面关于海飞丝的简要实现方案:



打算就先从这个基本流程入手。

首先是准备了一个头的模型,和主要的头发束模型,然后根据头皮的顶点,去生成头发。头发直接用9个顶点连成一条线。先在编辑器中把这些线画出来,方便查看效果。

private void OnDrawGizmos()
{
    if (!DebugDraw || GetVertices() == null || !ValidateImpl(false))
      return;

    var scalpToWorld = ScalpProvider.ToWorldMatrix;
    var vertices = GetVertices();

    for (var i = 1; i < vertices.Count; i++)
    {
      if (i % Segments == 0)
            continue;

      var vertex1 = scalpToWorld.MultiplyPoint3x4(vertices);
      var vertex2 = scalpToWorld.MultiplyPoint3x4(vertices);

      Gizmos.DrawLine(vertex1, vertex2);
    }

    var worldBounds = GetBounds();
    Gizmos.DrawWireCube(worldBounds.center, worldBounds.size);
}
大概是这样子的:



按照gpugems2里面的描述,我们要把每一个顶点想象成一个小珍珠,用Verlet积分来计算粒子的运动,并且增加头发顶点之间的约束,即头发长度会倾向于保持恒定避免拉伸。

头发顶点部分就是对应的网格顶点,而头发的连线部分则需要单独定义。

private void UpdateBodies(RenderParticle[] renderParticles)
{
    var renderSettings = settings.RenderSettings;
    var sizeY = settings.StandsSettings.Provider.GetSegmentsNum();
    //sizeY是单根头发的段数,i是第几根头发,y是这个头发里的第几段,t是百分比
    for (var i = 0; i < renderParticles.Length; i++)
    {
         var x = i / sizeY;
         var y = i % sizeY;
         var t = (float)y / sizeY;
         //下面是渲染的三个参数,后面渲染部分在解释
         var data = new RenderParticle
         {
             Color = ColorToVector(renderSettings.ColorProvider.GetColor(settings, x, y, sizeY)),
             Interpolation = Mathf.Clamp01(renderSettings.InterpolationCurve.Evaluate(t)),
             WavinessScale = Mathf.Clamp01(renderSettings.WavinessScaleCurve.Evaluate(t)) * renderSettings.WavinessScale,
             WavinessFrequency = Mathf.Clamp01(renderSettings.WavinessFrequencyCurve.Evaluate(t)) * renderSettings.WavinessFrequency,
         };

         renderParticles = data;
         }
}
曲面细分的部分需要利用到computershader先插值出顶点。

half3 CurveDirrection(half3 axis, half2 uv, half amplitude, half frequency)
{
//波动,uv.x就是当前所占百分比,uv.y是第几根,这个算法看上去比较难理解,其实就是每个轴,以圆形进行运动。大波浪的感觉。
        half angle = uv.x*frequency + uv.y;

        half c = cos(angle);
        half s = sin(angle);

        half3 vecX = half3(0, c, s);
        half3 vecY = half3(c, 0, s);
        half3 vecZ = half3(c, s, 0);

        half3 vec = normalize(vecX*axis.x + vecY*axis.y + vecZ*axis.z);

        return vec*amplitude;
}


RenderParticle GetSplineBodyData(int x, half t, uint sizeY)
{
//渲染部分因为不需要贝塞尔插值,只需要当前点和下一个点lerp一下就行了
        int sizeYm1 = sizeY - 1;
        int y = (int)(t*sizeY);
        half tStep = 1.0f / sizeY;
        half localT = (t % tStep) * sizeY;

        int startI = x*sizeY;

        int y1 = min(y, sizeYm1);
        int y2 = min(y + 1, sizeYm1);

        RenderParticle b1 = renderParticles;
        RenderParticle b2 = renderParticles;

        RenderParticle b;
        b.color = lerp(b1.color, b2.color, localT);
        b.interpolation = lerp(b1.interpolation, b2.interpolation, localT);
        b.wavinessScale = lerp(b1.wavinessScale, b2.wavinessScale, localT);
        b.wavinessFrequency = lerp(b1.wavinessFrequency, b2.wavinessFrequency, localT);

        return b;
}


float3 GetSplinePoint(int x, float t, uint sizeY)
{
//sizeYm1就是八个间隔,y就是整根头发对应下的这个部分的值。这里要找到原始的珍珠对应新的曲面细分下的百分比。localT就是把0-1这个区间划分成九个0-1,startI就是找到新的曲面细分对应原始的顶点的位置。例如x=1,那么起始点就是9,y0就是y-1,y1就是y,y2就是y+1,这里还处理的范围,防止越界。找到对应的三个点之后,就可以做贝塞尔曲线的插值。插值的部分理论上就是这个点位于这三个点中的具体位置。所以t取余1/sizeY之后,还要乘以sizeY,就是正确的插值位置。
        int sizeYm1 = sizeY - 1;
        int y = (uint)(t*sizeY);
        half tStep = 1.0f / sizeY;
        half localT = (t % tStep) * sizeY;

        int startI = x*sizeY;

        int y0 = max(0, y - 1);
        int y1 = min(y, sizeYm1);
        int y2 = min(y + 1, sizeYm1);

        float3 p0 = particles.position;
        float3 p1 = particles.position;
        float3 p2 = particles.position;

        float3 cPoint1 = (p0 + p1)*0.5f;
        float3 cPoint2 = (p1 + p2)*0.5f;

        return GetBezierPoint(cPoint1, p1, cPoint2, localT);
}



void CSTesselate (uint3 id : SV_DispatchThreadID)
{
//id是这个线程在所有线程组中的位置,因为定义的yz都是1,所以其实就是id.x就是第几个线程,而一个顶点对应一个线程。tessSegments是曲面细分出的顶点数,这个可以优化成离摄像机越远,顶点数就越少。
那么y在这里就是在一个曲面细分小段里的余数,x就是第几个曲面细分小段。t就是他所占曲面细分的百分比
segments是每根头发的段数。工程里是9.
        uint y = id.x % tessSegments;
        uint x = id.x / tessSegments;

        float t = y / (float)tessSegments;
        float tessStep = 1.0/tessSegments;

    float3 tessPosition =GetSplinePoint(x, saturate(t), segments);

        RenderParticle renderParticle = GetSplineBodyData(x, saturate(t), segments);
       
        float3 curve = CurveDirrection(normalize(wavinessAxis), half2(t, x), renderParticle.wavinessScale, renderParticle.wavinessFrequency);

        TessRenderParticle tessParticle;

        tessParticle.position = tessPosition + curve;
        tessParticle.tangent = float3(0,0,0);
        tessParticle.color = renderParticle.color;
        tessParticle.interpolation = renderParticle.interpolation;
        tessRenderParticles = tessParticle;

        AllMemoryBarrierWithGroupSync();

        int sign = y == 0 ? -1 : 1;
        tessRenderParticles.tangent = normalize(tessParticle.position - tessRenderParticles.position)*sign;
}
而着色器还需要头发索引。

private static List<int> ProcessIndicesForMesh(int startIndex, List<Vector3> scalpVertices, List<int> scalpIndices, List<Vector3> hairVertices, int segments, float accuracy = Accuracy)
{
   var hairIndices = new List<int>();
//遍历头皮的顶点,每三个构成一个三角形,对应的头发也要是3的倍数才行。遍历头发的顶点,将靠近头皮的部分一一对应,最终,每一个头皮的顶点都获得对应的一个头发的顶点的索引。
   for (var i = 0; i < scalpIndices.Count; i++)
   {
         var index = scalpIndices;
         var scalpVertex = scalpVertices;

         if (i % 3 == 0)
            FixNotCompletedPolygon(hairIndices);

         for (var j = 0; j < hairVertices.Count; j += segments)
         {
            var hairVertex = hairVertices;

            if ((hairVertex - scalpVertex).sqrMagnitude < accuracy*accuracy)
            {
                   hairIndices.Add(startIndex + j);
                   break;
            }
          }
      }

      FixNotCompletedPolygon(hairIndices);
      return hairIndices;
}

public static List<int> ProcessIndices(List<int> scalpIndices, List<Vector3> scalpVertices, List<List<Vector3>> hairVerticesGroups, int segments, float accuracy = Accuracy)
{
      var hairIndices = new List<int>();

      var grouStartIndex = 0;
      foreach (var hairVertices in hairVerticesGroups)
      {
         var groupIndices = ProcessIndicesForMesh(grouStartIndex, scalpVertices, scalpIndices, hairVertices, segments, accuracy);
         hairIndices.AddRange(groupIndices);

         grouStartIndex += hairVertices.Count;
       }
//拿到所有的索引之后,再除以9,就是每个顶点对应的头发第几根
       for (var i = 0; i < hairIndices.Count; i++)
       {
            hairIndices = hairIndices / segments;
       }

       return hairIndices;
}
接下来是插值,因为我们网格中定义的主发束就那么几百根,但人的头发要远远多于这些,所以我们需要使用插值来获得浓密的头发。这在gpugemes中也有描述:





//这是使用重心坐标插值,一开始使用了0.9,0.05,0.05的坐标,保持头发的稳定性,后面的部分就随机,总共生成了64个插值发束
private void Gen(bool forceUpdate = false)
      {
            var off = 0.1f;
            var n = 2;

            //oldN = n;
            var m = 1 - off;
            var mm = (1 - m) * 0.5f;

            barycentric.Reset();
            Split(new Vector3(m, mm, mm), new Vector3(mm, m, mm), new Vector3(mm, mm, m), n);

            while (barycentric.Count < MaxCount)
            {
                var k = GetRandomK();
                if (!barycentric.Contains(k))
                  barycentric.Add(GetRandomK());
            }

            settings.RuntimeData.Barycentrics.PushData();
      }

      private void Split(Vector3 b1, Vector3 b2, Vector3 b3, int steps)
      {
            steps--;

            TryAdd(b1);
            TryAdd(b2);
            TryAdd(b3);

            var n1 = (b1 + b2) * 0.5f;
            var n2 = (b2 + b3) * 0.5f;
            var n3 = (b3 + b1) * 0.5f;

            if (steps < 0)
                return;

            Split(b1, n1, n3, steps);
            Split(b2, n1, n2, steps);
            Split(b3, n2, n3, steps);
            Split(n1, n2, n3, steps);
      }

      private void TryAdd(Vector3 v)
      {
            if (!barycentric.Contains(v))
            {            
                barycentric.Add(v);
            }
      }


      private Vector3 GetRandomK()
      {
            var ka = Random.Range(0f, 1f);
            var kb = Random.Range(0f, 1f);

            if (ka + kb > 1)
            {
                ka = 1 - ka;
                kb = 1 - kb;
            }

            var kc = 1 - (ka + kb);
            return new Vector3(ka, kb, kc);
      }



---------------------
作者:yxriyin
来源:CSDN
原文:https://blog.csdn.net/yxriyin/article/details/88727377
版权声明:本文为博主原创文章,转载请附上博文链接!


准备工作基本完成,然后就可以开始搞着色了。先从曲面细分开始,曲面细分的基础知识可以看凯奥斯大佬的文章:

https://zhuanlan.zhihu.com/p/42550699

domain是isoline,分割模式是integer,输出是线段,输出的控制点是3个,对于每3个一组的头发根节点,
输出沿着y轴生成等y值的水平线图元。具体可以参考https://www.jianshu.com/p/3d974e69f842





HS_OUTPUT HS(InputPatch<VS_OUTPUT, 3> ip, uint id : SV_OutputControlPointID)
{
      HS_OUTPUT output;
      output.id = ip.id;
      return output;
}

float3 GetBarycentric(float3 a, float3 b, float3 c, fixed3 k)
{
      return a*k.x + b*k.y + c*k.z;
}

fixed GetBarycentricFixed(fixed a, fixed b, fixed c, fixed3 k)
{
      return a*k.x + b*k.y + c*k.z;
}
//op是细分完毕之后的等y值线段
StepData GetPosition(OutputPatch<HS_OUTPUT, 3> op, fixed2 uv)
{
//根据uv.y*64,就可以知道插值在第几个重心坐标,而index就是uv.x乘以第几段。
      fixed3 barycentric = _Barycentrics;
            
      half index = uv.x*_TessFactor.y;
      根据重心坐标,可以得到插值后的长度,再乘以Index,就是这个顶点离根节点的长度。
      half length = GetBarycentricFixed(_Length.x, _Length.y, _Length.z, barycentric);
      half length1 = length*index;
      //op的id就是对应的根节点的id,id乘以段数,加上length1,就可以得到珍珠的下标。
      ParticleData p1 = _Particles.id*_TessFactor.y + length1];
      ParticleData p2 = _Particles.id*_TessFactor.y + length1];
      ParticleData p3 = _Particles.id*_TessFactor.y + length1];
      //根据这三个下标,再和重心坐标进行插值,得到位置后,在根据参数进行最终插值。
      float3 position = GetBarycentric(p1.position, p2.position, p3.position, barycentric);
      position = lerp(position, p1.position, p1.interpolation);

      //切线也是按照这种方式处理
      float3 tangent = GetBarycentric(p1.tangent, p2.tangent, p3.tangent, barycentric);
      tangent = lerp(tangent, p1.tangent, p1.interpolation);      
      
      float3 color = GetBarycentric(p1.color, p2.color, p3.color, barycentric);
      color = lerp(color, p1.color, p1.interpolation);

      StepData data;
      data.position = position;
      data.tangent = tangent;
      data.color = color;
      return data;
}





DS_OUTPUT DS(HS_CONSTANT_OUTPUT input, OutputPatch<HS_OUTPUT, 3> op, float2 uv : SV_DomainLocation)
{

      DS_OUTPUT output;

      StepData step = GetPosition(op, uv);

      float4 lightData = LightData(step.position);
      float3 lightDir = lightData.xyz;
      half attenuation = lightData.w;

      float3 viewDir = ViewDir(step.position);

      //光照模型并没有使用gpugems2中的Marschner,而是使用了更简单的Kajiya-Kay Model
      //主要参考了https://blog.csdn.net/noahzuo/article/details/51162472
      //法线用了近似计算,然后与光线方向点积,菲尼尔根据法线和视线的点积,夹角越大就越亮,
      half3 psevdoNormal = normalize(step.position - _LightCenter);
      attenuation *= Diffuse(psevdoNormal, lightDir, _Diffuse) + Fresnel(psevdoNormal, viewDir, _FresnelPower)*_FresnelAtten;


      half shift = saturate(tex2Dlod(_ColorTex, half4(uv.yx, 0, 0)).r - 0.5);
      fixed thickness = 1 - pow(2, -10 * (1 - uv.x));//curve

      output.vertex = float4(step.position, 1);
      output.tangent = step.tangent;
      output.normal = cross(step.tangent, cross(lightDir, step.tangent));
      output.viewDir = viewDir;
      output.lightDir = lightDir;
      output.factor = half4(saturate(attenuation), shift, 0, 0);
      output.right = normalize(cross(step.tangent, output.viewDir))*thickness*_StandWidth;
      output.color = step.color;

      return output;                        
}
曲面细分做完了,接下来就是几何着色器。


GS_OUTPUT CopyToFragment(DS_OUTPUT v, float4 position)
{
//算出世界坐标和裁剪空间坐标
      float4 objectPosition = mul(unity_WorldToObject, position);
      float4 clipPosition = UnityObjectToClipPos(objectPosition);

      GS_OUTPUT output;

      output.pos = clipPosition;
      output.tangent = v.tangent;
      output.normal = v.normal;
      output.viewDir = v.viewDir;
      output.lightDir = v.lightDir;
      output.factor = v.factor;
      output.color = v.color;

      TRANSFER_VERTEX_TO_FRAGMENT(output);
      UNITY_TRANSFER_FOG(output, output.pos);
      
      return output;
}

void GS(line DS_OUTPUT p, inout TriangleStream<GS_OUTPUT> triStream)
{
      float4 v;
      v = float4(p.vertex + p.right, 1);
      v = float4(p.vertex + p.right, 1);
      v = float4(p.vertex - p.right, 1);
      v = float4(p.vertex - p.right, 1);

      triStream.Append(CopyToFragment(p, v));
      triStream.Append(CopyToFragment(p, v));
      triStream.Append(CopyToFragment(p, v));
      triStream.Append(CopyToFragment(p, v));
}
最后是片段着色器

//这事各项异性高光,将切线沿着法线进行一定程度的移动,可以展现头发的高光
half3 ShiftTangent(half3 tangent, half3 normal, half shift)
{
      return normalize(tangent + shift * normal);
}

这就是标准的Kajiya-Kay Model公式
half Specular(half3 tangent, half3 viewDir, half3 lightDir, half exponent)
{
      half3 h = normalize(viewDir + lightDir);
      half dotTH = dot(tangent, h);
      half sinTH = sqrt(1.0 - dotTH * dotTH);
      half dirAtten = smoothstep(-1.0, 0.0, dotTH);
      return dirAtten * pow(sinTH, exponent);
}
fixed3 SpecularColor(GS_OUTPUT i, fixed shift, half width1, half width2, fixed3 color)
{
      half3 tangent1 = ShiftTangent(i.tangent, i.normal, i.factor.y - shift);
      half3 tangent2 = ShiftTangent(i.tangent, i.normal, i.factor.y + shift);

      half3 specular1 = Specular(tangent1, i.viewDir, i.lightDir, width1);
      half3 specular2 = Specular(tangent2, i.viewDir, i.lightDir, width2);

      return color * specular1*specular2;
}

float4 FS(GS_OUTPUT i) :SV_Target
{
      fixed3 lightColor = _LightColor0 * LIGHT_ATTENUATION(i)*i.factor.x;

      fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb*i.color;
      fixed3 diffuse = Diffuse(i.normal, i.lightDir, _Diffuse)*i.color*lightColor;
      fixed3 specular = SpecularColor(i, _SpecularShift, _PrimarySpecular, _SecondarySpecular, _SpecularColor)*(max(lightColor, 0.35));

      fixed4 final = fixed4(diffuse + specular + ambient, 1);
      UNITY_APPLY_FOG(i.fogCoord, final);
      return final;
}
终于,头发有了基础的样子。要想人生过得去,带点绿。





接下来就要开始处理动力学的部分。


void CSIntegrate (uint3 id : SV_DispatchThreadID)
{
    if(id.x >= particlesLength)
      return;

//重力加上风,移动过程中和以前的位置差,那么速度就是位置差加上加速度
      Particle particle = particles;

      float3 acceleration = (gravity + wind)*step;

      float3 difference = particle.position - particle.lastPosition;
      float3 velocity = difference*invDrag + acceleration;
      float3 nextPosition = particle.position + velocity;

      particle.lastPosition = particle.position;
      particle.position = nextPosition;
      
      particles = particle;
}


float3 DistanceJointSolveImpl(float3 position1, float3 position2, float distance)
{
//将两个位置差和初始位置差进行比较,就可以得到缩放的百分比。
      float3 relPosition = position1 - position2;
      float actualDistance = length(relPosition);

      float penetration = (distance - actualDistance) / actualDistance;
      return relPosition*penetration;
}

void DistanceJointsSolve(DistanceJoint joint)
{
//取到这两个珍珠点,得到矫正数,两个都进行矫正。这里因为是并行的,所以不能连续矫正,只能断开成两组group分别矫正。
      Particle particle1 = particles;
      Particle particle2 = particles;

      float3 correction = DistanceJointSolveImpl(particle1.position, particle2.position, joint.distance)*joint.elasticity*step*0.5f;

      particle1.position += correction;
      particle2.position -= correction;

      particles = particle1;
      particles = particle2;
}


void CSDistanceJoints (uint3 id : SV_DispatchThreadID)
{
      int i = startGroup + id.x;
      if(i < startGroup + sizeGroup)
      {
                DistanceJointsSolve(distanceJoints);
      }
}

很早就看到文章说只是这样的约束无法处理迅速移动的情况,会导致头发被拉长,于是需要一个专门处理迅速移动的判断

float3 DistanceJointSolveImpl(float3 position1, float3 position2, float distance)
{
      float3 relPosition = position1 - position2;
      float actualDistance = length(relPosition);

      float penetration = (distance - actualDistance) / actualDistance;
      return relPosition*penetration;
}

//按照现在的距离和上次的距离的比较,得到的矫正全部应用到第二个点的位置。
void DistanceJointsSolve(uint i1, uint i2, float distance)
{
      Particle particle1 = particles;
      Particle particle2 = particles;

      float3 correction = DistanceJointSolveImpl(particle1.position, particle2.position, distance)*step;

      particle2.position -= correction;
      particle2.lastPosition -= correction*0.9;

      particles = particle2;
}


void CSSplineJoints (uint3 id : SV_DispatchThreadID)
{
    if(id.x*segments >= pointJointsLength)
      return;
      
//从本地坐标转到世界坐标之后的两个点
      for(uint i = 1; i < segments; i++)
      {
                uint index = id.x*segments + i;

                PointJoint joint1 = pointJoints;
                float4x4 m1 = transforms;
                float3 guidePosition1 = mul(m1, float4(joint1.position, 1.0)).xyz;
      
                PointJoint joint2 = pointJoints;
                float4x4 m2 = transforms;
                float3 guidePosition2 = mul(m2, float4(joint2.position, 1.0)).xyz;

                float distance = length(guidePosition2 - guidePosition1);
                DistanceJointsSolve(joint1.bodyId, joint2.bodyId, distance);
      }
}
这样子,基本的动力学算是完成了。还剩下最后的影子问题了。

本来直接用自带的影子,发现精度太低,那么只能用老办法单独绘制角色阴影了,因为这个以前做过,就暂时先不做了。

最后我想到了大佬Kvant Wig的那个骚气的男子,就把他开源工程里的那个模型下载下来,配置了一下,然后跑起来看看。

感觉还行,但由于头发中间有点镂空,感觉掉发一样,于是干脆把头皮搞成和头发一样的颜色,这样就好多了。

最终演示发现头发和碰撞体依然有穿插,再精确判断电脑就有点卡了,就适当增加了以下头发的半径。暂时先这样。

最后展示结果:











最后是插件地址:

https://assetstore.unity.com/packages/vfx/shaders/best-hair-142637
---------------------


cgjoy_8308 发表于 2019-3-29 10:25:16

有点骚 大佬

vitor 发表于 2019-3-29 10:11:34

niu比
页: [1]
查看完整版本: 海飞丝头发的研究和实现