在一般的游戏开发流程中,类似头发的毛发会使用面片进行制作,但对于短小但覆盖范围广的毛发与绒毛等则不适宜使用面片进行制作。

一般来说,这种短毛会使用多Pass的方法进行制作。但现代的引擎对于这种客制化的支持越来越少,同时该种实现方法的效果与优化也有需要一定Trick来进行实现。

样例展示

样例展示

一、原理简介

所谓多Pass毛发渲染,就是将模型一层层挤出,每一层使用噪波贴图控制不透明度。随着不透明区域的缩小,就可以连成毛发的样子。

当然由于是多层渲染,因此没跟毛发都只是一层层的切片。要想效果更好就需要切片数量增加,但切片数量增加又注定会导致更差的性能,因此需要在制作中通过各种Trick来使使用尽量少的层数达到更好的效果。

多Pass毛发渲染原理

在正式进入毛发相关效果的制作之前,要首先解决如何能做到“多Pass”,即多次渲染模型的问题。

二、多Pass实现方案

(一)Unity URP(14.0.11)

与Bulid-In管线不同,Unity URP默认的Shader是不支持自定义多Pass渲染的。但URP的Render Feature功能可以较为自由地对渲染流程做一些修改,因此可以使用Render Feature实现多Pass的效果。

1. Render Object

Render Object就是Unity已经实现的一个可以渲染特定Pass的Render Feature,同时可以调节一些参数。

  • 优势:方便;

  • 劣势:不自由;

在工程选用的Renderer中添加一个Render Object,然后加入要渲染的Pass的LightMode即可(在这个例子中,加入Test即可)。

Render Object 示例

参数:

  • Event:该Pass会在哪个渲染阶段被执行;

  • Filters:哪些Pass会被执行(若Pass未执行重点检查该部分设置);

  • Queue:Pass的渲染队列;

  • LayerMask:Pass所在层;

  • LightMode Tags:Pass的Light Mode Tag;

  • Overrides:重载;

2. Render Feature

Render Feature可以灵活的在渲染的各个阶段插入Command Buffer,进行渲染流程的灵活修改。

  • 优势:可实现多种客制化需求;

  • 劣势:要写代码;

创建Custom Render Feature脚本后其中会自动包含一个Scriptable Render Pass类与常用的函数。在自定义Render Pass的Execute函数中就是渲染时要执行的逻辑,在这个例子中要想渲染Test Pass,就要使用Tags将Test Pass通过Command Buffer进行渲染。

Custom Pass Execute函数示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// 获取一个新 command buffer 并为其命名
CommandBuffer cmd = CommandBufferPool.Get("TestRenderPass");
// 设置并执行 command buffer
FilteringSettings filteringSettings = new FilteringSettings(RenderQueueRange.all);
DrawingSettings drawingSettings = CreateDrawingSettings(new ShaderTagId("Test"), ref renderingData, SortingCriteria.None);
context.ExecuteCommandBuffer(cmd);
// 绘制
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings);
// 释放
CommandBufferPool.Release(cmd);
}

在Custom Render Feature中进行Enqueue即可,可注入点如下:

Render Pass 注入点

特定Pass多次渲染

对于多Pass毛发这种要将某个Pass进行多次渲染的可以使用循环,方便针对不同设备进行调整。

1
2
3
4
5
6
7
8
9
10
11
// 循环多次渲染
for (int i = 1; i <= _furStep; i++)
{
// 映射到 0-1
var step = 1.0f / _furStep * i;
cmd.SetGlobalFloat("_FurStep", step);
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
// 绘制
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings);
}

自定义Pass与渲染次数

对于有多种长度毛发的情况,毛发长度不同自然需要不同的Pass数。若使用Render Feature可以分组进行调整。

自定义不同 Pass 渲染次数

3. 多材质

在脚本中给模型多赋材质,多的材质会在最后一个插槽中重复渲染,性能会比Render Feature稍微好一点;

(二)UE

三、毛发效果实现方案

(一)多Pass效果初步实现

随着层数靠外,渐渐降低不透明度:

初步多 Pass 效果

使用噪波图参与Alpha的变化,并根据层位置制作毛发根部的AO(越靠近底部越暗):

毛发噪波图
未加根部AO 添加根部AO
无根部 AO 效果 添加根部 AO 效果

现在看起来像刺猬一样,因为毛直挺挺的没有动感。

(二)毛发动感

使用UV或顶点对层每层进行偏移制作动感。也可加入噪波模拟风力影响。

1. UV偏移

基于Layer的UV偏移,注意Noise与Base Map的UV应同时偏移。注意偏移后根部应移动较少,毛发呈曲线,因此要用Power控制分层参数。

  • 优势:方便,高性能;

  • 劣势:对UV有要求,基于世界空间的偏移制作困难;

参考:

1
v.uv = input.texcoord + _FurUVOffset.xy * pow(_FurStep, _FurUVOffset_E) * 0.1f;

2. 顶点偏移

每层的顶点偏移,可以在世界空间计算,可以制作出更高级的实时效果(如重力)。

  • 优势:可在世界空间制作基于物理的实时效果;

  • 劣势:需要一些较复杂计算;

直接进行位置偏移可能会导致面的穿插,同时在与偏移方向相反面的毛发应沿切线方向移动,如应用重力时,上方的毛发不应向下偏而应向四周偏。如下图:

顶点偏移示例图

即使用顶点法线方向与偏移方向确定顶点是向切线方向偏移还是直接向偏移方向偏移,若是向切线方向则计算出与法线和偏移方向处于同一个平面的切线。切线方向可以经过两次叉乘得到。

可添加多种控制参数,如底部毛发的伸张系数、顶部毛发的下压系数等。

原效果 顶点偏移效果 控制底部毛发伸张系数 控制顶部毛发下压系数

参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 计算顶点偏移
if (length(_FurWorldOffset.xyz) > 0)
{
// 计算World Offset切线 给Normal加个随机小数防止叉乘为0 或加噪波也可
float3 worldOffsetBitangent = cross(worldNormal + float3(0.012, 0.008, -0.012), _FurWorldOffset.xyz);
float3 worldOffsetTangent = SafeNormalize(cross(worldNormal, worldOffsetBitangent));
worldOffsetTangent *= sign(dot(_FurWorldOffset.xyz, worldOffsetTangent)) > 0 ? 1.0 : -1.0;

// 对offset方向与长度进行插值
float alpha = saturate(dot(SafeNormalize(_FurWorldOffset.xyz), worldNormal));
float3 worldOffsetDir = lerp(lerp(worldOffsetTangent, _FurWorldOffset.xyz, _FWO_Top_Press), _FurWorldOffset.xyz, alpha);
worldOffsetDir = SafeNormalize(worldOffsetDir);
float offsetFinalLength = length(_FurWorldOffset.xyz) * lerp(1, _FWO_Bottom_Stretch, alpha);

// 应用World Offset
worldPos += worldOffsetDir * offsetFinalLength * pow(_FurStep,_FurWorldOffset.w) * 0.1f;
}

或许有更好更物理的计算方法,可以使毛发长度保持不变,待研究;

(三)光照

1. 毛发根部AO

直接使用线性的Fur Step参数效果不好,可以使用Power和Bias简单模拟一下毛发遮蔽上的非线性。

1
half3 furAO = lerp(_FurAOTint.xyz, 1, pow(furStep, _FurAOPower) + _FurAOBias * 0.1f);
调整前 调整后

2. 环境光

将SH光照信息与结合AO:

无AO 加毛发AO 调整AO曲线

3. 直射光

直射光按照正常进行渲染效果很平,明暗交界线太整齐了。正常的毛发在光照下,明暗交接线处应有大量毛发未被遮挡,呈现尖端亮根部暗的状态。因此这里要对光照计算结果进行处理。

正常光照计算 添加处理 呈现尖端亮根部暗的毛发

参考:

1
2
// 使用FurStep控制NdotL 制作毛发透光效果
half NdotL = saturate(dot(input.normalWS, mainLight.direction) + (furStep - 0.5) * 2 * _FurLightFilter);

4. 透射光

在背光时,毛发由于透光会产生比亮面相同甚至更亮的透光效果,可以使用 1 - (-VdotL) 进行制作。

现实中头发的透射光

在背光状态下,添加透射光的效果:

无透射光 添加透射光

实现参考:

1
2
3
4
5
6
// 直射光的透射光
half lightTransmitRim = pow(saturate(1 + NdotL), 5);
half VdotL = dot(viewDir, mainLight.direction);
half lightTransmit = pow(saturate(-1 * VdotL), 5);
lightTransmit *= lightTransmitRim;
mainLightColor += lightTransmit * attenuatedLightColor * _FurLightTransmit;

5. 轮廓光

与透射光类似,轮廓光是环境光的透射光。使用菲涅尔进行制作即可。

环境光 环境光+轮廓光 最终效果 最终效果+轮廓光

实现参考:

1
2
3
4
// 轮廓光
half fresnel = pow(1 - saturate(dot(input.normalWS, viewDir)), 3);
half3 rimLight = fresnel * SH * _FurRimLight * furAO;
envColor += rimLight;

6. 最终光照效果

将上述四种光照结合:

最终光照效果

(四)各向异性高光

柔软的绒毛是没有高光的,但是如果要制作动物皮那种较硬的毛发则需要制作带有各向异性的高光。

毛发的高光包含两层:一层低频高光,面积较大,亮度暗,且带有一定基础色;一层高频高光,亮度高;

头发的各向异性高光

1. Kajiya-Kay Model

https://web.engr.oregonstate.edu/~mjb/cs519/Projects/Papers/HairRendering.pdf

在计算出高光后,使用Fur Step压暗根部,使用noise值控制只有毛发中间发光:

无高光 低频高光 高频高光

实现参考:

1
2
3
4
5
6
7
8
half3 worldBinormal = normalize(cross(input.normalWS, input.tangentWS));
half3 T = normalize(worldBinormal + input.normalWS * _AnisoBent.x * 0.1);
half spec1 = StrandSpecular(T, viewDir, mainLight.direction, _AnisoSpec1.x) * _AnisoSpec1.y;
half spec2 = StrandSpecular(T, viewDir, mainLight.direction, _AnisoSpec2.x) * _AnisoSpec2.y;
spec1 *= lerp(albedo, attenuatedLightColor, _AnisoSpec1.z) * pow(noise, _AnisoSpec1.w);
spec2 *= lerp(albedo, attenuatedLightColor, _AnisoSpec2.z) * pow(noise, _AnisoSpec2.w);
specularColor = (spec1 + spec2) * saturate(NdotL) * furAO * furStep;

2. Marschner Model

https://graphics.stanford.edu/papers/hair/hair-sg03final.pdf

(五)噪波图

1. 噪波形态选择

不同噪波形态可以制作出不同的毛发效果。

噪波 噪波形态 效果 适用
毛发
绒毛
等
摇粒绒

2. 噪波去重

对于较大平面,需要对毛发形态噪波贴图做比较多次Tile,很容易出现重复纹样。可在噪波贴图RG通道中存两张仅有随机种子不同的图,在Shader中使用不同Offset进行Screen混合。

要注意,对噪波进行混合可能会导致毛发密度发生变化,因此在混合后要使用参数进行调节。

未去重 去重 去重 + 调整

参考实现:

1
2
3
4
5
6
7
// 采两个Noise并用Blend Screen算法进行混合
float2 noiseUV = TRANSFORM_TEX(input.uv, _FurNoise);
half noise1 = SAMPLE_TEXTURE2D(_FurNoise, sampler_FurNoise, noiseUV).x;
half noise2 = SAMPLE_TEXTURE2D(_FurNoise, sampler_FurNoise, noiseUV * half2(0.618, 1.618)).y;
half noise = 1 - (1 - noise1) * (1 - noise2);
noise = pow(noise, _FurAlphaPower);

(六)性能优化

1. 层数优化

对Alpha进行处理,使最外层显示尽量多的像素:

使更外层的层显示更多的像素

可以使用代码对现有Noise进行处理,也可在Noise贴图制作时就进行调整。

优化前 30Pass 优化前 10Pass 优化后 10Pass

代码处理参考:

1
2
half alpha = saturate((noise - pow(furStep, _FurPower))* _FurMulti);

2. 层分布优化

对层数分布做调整,使其更多的被分布在偏外层的区域。可在Shader中优化也可在Render Feature(多Pass实现处)中进行优化。

注意:尖端由于透明度低,有过多的层数效果也变化不大,因此应该在尖端和根部不变的情况下,让中间部分的层数向外偏移。

层分布优化示意图及层分布曲线:

层分布优化示例

使用曲线控制层分布以进行优化

30 Pass 20Pass 20Pass + 分布优化

可以发现优化后毛发尖端由于Pass少导致的瑕疵被显著优化。

3. 光照

尽量进行逐顶点的计算。

参考资料

管线相关

https://docs.unity.cn/Packages/com.unity.render-pipelines.universal@12.1/manual/containers/create-custom-renderer-feature-1.html

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

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

毛发相关

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

https://mp.weixin.qq.com/s/aIWMEO5Qa2gNn2yCmnHbOg

https://blog.csdn.net/qjh5606/article/details/118117176

https://www.zhihu.com/tardis/zm/art/341317623?source_id=1003