【学习】原神材质ShaderGraph实现笔记

本文主要是参考学习了B站大佬的原视频【虚幻&Unity】两种引擎 原神风格基础卡通渲染 完整流程,这里主要是自己的心得和笔记。
原视频Unity是用的HLSL,UE4使用了节点。为了能深入理解,这里我找了个类似的芙宁娜模型和贴图,自己按照理解使用ShaderGraph来实现。由于模型和视频有一些小区别自己做了一些微调。
感慨一下,ShaderGraph做复杂一点的材质就有点力不从心了,节点太多远不如代码简洁。

1.初期设置

首先创建空白URP工程,创建ShaderGraph材质,我这里使用Lit类型,用Unlit会导致无法接收阴影,其他效果都一样。使用Unlit的话,最终全部输出到BaseColor。使用Lit的话,由于这里我们的高光和光照效果全部自己重新实现,所以BaseColor置为黑色,Smoothness为0,Metallic为1,后续的结果全部输出到Emission上,以排除材质本身的干扰。
导入模型和贴图,除了Diffuse贴图,其他贴图都取消SRGB的选项。
法线贴图设置为法线图的类型。
对于Ramp图,需要取消Mimap,否则会出现距离变化时人物颜色突然跳变的情况。
在这里插入图片描述
原始Blender模型导出后材质分得非常的多,我这里划分了四个Shader:
1.身体是主要的着色器,核心的效果都在这里
2.脸部需要特殊处理,只考虑光左右侧的影响,采样一张特殊的伦勃朗光图
3.身体描边,视频中是使用传统的将顶点向法线方向挤出然后仅渲染背面的方法,但是ShaderGraph不能实现多Pass,正好原模型中有模型向外挤压翻转制作的描边,这里就直接使用Unlit的黑色即可。
身体部分有三套贴图Body,Cloth,EffectHair,分出三个材质球分别贴贴图,着色器可以通用。

2.身体着色器

接下来开始调解身体着色器,在这里首先需要解释一下各贴图的含义和作用

贴图含义

(1)Diffuse贴图:

采样后直接作为BaseColor,如果直接连到输出的话,呈现的是不考虑光照高光等效果,可以看出由于纹理提供大量细节,这样看起来已经比较细致。
仅贴完Diffuse贴图作为Unlit输出的效果

(2)LightMap贴图:

视频中又称作魔法图,规则比较复杂,此贴图的各个通道分别存储了不同的信息。
各通道信息

R通道: 高光的枚举,划分金属与非金属。数值为1时为金属,小于1为非金属,非金属时其数值可以直接作为高光强度使用。
G通道: 指示受光照的属性。
黑色部分0不受光照影响。
灰色部分(0.5)受光照影响,有光照为亮部,无光照为暗部。
白色部分1为恒定自发光,不受光照影响。
B通道: 高光的形状,用于修饰高光的细节。在计算出的高光结果上相乘使得高光呈现特定的形状。
A通道: 材质组的枚举,不同的值代表不同的材质组,每个材质组会在 ShadowRamp图中采样不同的行,使得不同材质组的明暗过度呈现不同的变化,这里一共有五个枚举值(0,0.3,0.5,0.7,1),对应后面ShadowRamp的不同V坐标。

(3)Normalmap:

法线贴图,就是最常见的法线贴图,这里仅身体着色器有法线贴图。

(4)ShadowRamp贴图

在这里插入图片描述

这里的贴图尺寸为256*20,一共有十行,每行两个像素值是相同的。行数对应材质组的枚举,按照如下规则:
使用A通道的采样结果作为基准值。
0对应第1行,0.3对应第4行,0.5对应第3行,0.7对应第5行,1对应第2行。
夜晚时在上面的行数上+5,即0在白天时对应第1行,夜晚对应第6行,其他依次类推。
U坐标按照受光照强度从暗到亮来采样,前面的G通道中划分了光照类型,仅有灰部正常受到光照,再结合材质组对应V坐标,其采样的结果乘以BaseColor可得到灰部的颜色值。

(5)Matcap贴图

在这里插入图片描述

仅针对R通道划分为金属的部分,会叠加Matcap采样结果,与高光叠加后强化金属的反射感

ShaderGraph编写

接下来开始正式的Shader编写

(1)半兰伯特照明

首先获取法线,由于部分材质有法线贴图,所以这里将采样的结果乘以强度转到世界空间后,与世界空间的顶点法线做一个混合得到最终的世界空间法线N
在这里插入图片描述
ShaderGraph中的MainLightDirection是从光源指向顶点的,其方向为-L,取反后得到L,
使用Dot(N,L)可以得到标准的兰伯特照明,由于兰伯特照明的结果是从-1到1的,暗部过多,所以根据半兰伯特的经典公式将结果从(-1,1)映射到(0,1)得到HalfLambert,而由于半兰伯特的结果过渡过于平滑,变化比较小,对半兰伯特的结果平方处理作为基准的照明值
即:pow((0.5*Dot(N,L)+0.5),2),从节点的预览图可以很清晰的看到明暗分布的变化
在这里插入图片描述
这里得到的计算结果作为卡通着色还是比较平滑,我们使用SmoothStep(0.42,0.45,X)来处理上面的结果,可以构建强烈的明暗分界线,我们希望在暗部的阴影颜色有明显的变化,而ShadowRamp图中颜色变化主要出现在图的右侧部分,所以这里使用LambertStep=SmoothStep(0.2,0.4,X)的结果去采样ShadowRamp图,使得暗部靠近明暗分界线处有更明显的变化。
主要是为了达到红框处的效果,下图是游戏中截图
在这里插入图片描述
可以参考这里的预览来理解这里几个参数的含义,这里那个相减只是为了展示这一处作用的区域,实际后面会删掉。
上面的SmoothStep(0.2,0.4)作为ShadowRamp的U坐标进行采样,为了避免采样到边界Clamp到(0.03,0.97)

然后就是根据之前LightMap定义中的A通道来求采样的V坐标,将不同的枚举值映射到行数再映射到V坐标,原视频是直接计算转换,我这里列出了行数看起来清晰一点。
这里的规则是白天的情况下,0对应第1行,0.3对应第4行,0.5对应第3行,0.7对应第5行,1对应第2行。
夜晚的话行数直接+5,可以根据光源方向的Y值来判断是白天还是夜晚。
这里行数除以10,再减0.05(往中心偏移避免采样到交接处)可以得到V坐标,可用于ShadowRamp图的采样,会得到灰部阴影颜色。
将U坐标强制定位0.03再结合这里的V坐标,可以得到暗部阴影颜色。
这里结构比较啰嗦,我就简单截个图,感兴趣可以看最后的项目文件。
在这里插入图片描述

再看回Lightmap的G通道,按照0,0.5,1划分为暗部,灰部,亮部。
暗部为恒暗,不受光照影响,最终的Diffuse值为BaseColor暗部阴影颜色自定义参数,主要是一些褶皱
亮部为自发光也不受光照影响,直接为BaseColor
灰部需要考虑光照影响,根据前面半兰伯特算出的明暗分界线,亮部也为BaseColor,暗部为BaseColor*采样的灰部阴影颜色*自定义阴影颜色。
这里就是各种插值混合,就不截图了,到这里人物身上已经有基本的明暗分界和阴影颜色过渡,特别是我圈出来的部分(脸部后面另外处理)
在这里插入图片描述

(2)BlinnPhong高光

BlinnPhong高光也是非常基础的内容,L+V得到半途矢量,归一化后与N点乘,其结果钳制在(0,1)后,将结果高光指数次方则得到BlinnPhong高光,结果乘以R通道的高光强度和B通道的高光形状可以得到基本的高光。
视频中提到金属高光是有颜色的下,所以需要乘以BaseColor,这个我查了一下可以 参考这里提到了(金属的高光由于无法进入物质内部发生作用再次射出,所以金属是不存在漫反射的。但是在金属表面部分频率光线容易被电子吸收,而仅反射出来部分光线。所以PBR使用BaseColor的颜色来表示金属的光线反射情况,也就是反射率。)
原视频中的代码我不是太看得懂,他将高光分成的金属高光和非金属高光两个部分。
非金属部分公式如下:
nonMetallicSpec = step(1.04-BlinnPhong,ilm.b)*ilm.r*Ks_nonMetallic
这里的step(1.04-BlinnPhong,ilm.b)是一个裁边视角高光,可以理解为参照BlinnPhong的高光范围,以高光遮罩的强度将高光向内缩进一定宽度。为什么要用1.04呢,因为当高光遮罩值为1时,用1减会导致BlinnPhong为0的值也通过Step函数,导致背面也出现高光,这里数值增加一点点可以剔除整个背光面。
金属部分为:
MetallicSpec = BlinnPhong*ilm.b*(LambertStep*0.8+0.2)*BaseColor*Ks_Metallic
这里的裁边我就直接相乘了,然后这个LambertStep映射到(0.2,1)的单位再相乘没看懂,因为BlinnPhong中已经排除不受光照的部分了,这里的再去乘一个半兰伯特的截断映射不知道代表什么意义。

这一段搜了一下,不同的教程实现类似的效果做法都不一样,我自己也没明显的看出有什么区别。这里就不截图了。我感觉直接连乘的效果就OK了。
叠加高光后呈现的效果如下:
在这里插入图片描述

(3)轮廓光

使用深度图差值提取边缘,就可以得到边缘光。
如果得到的边缘光太硬,可以使用Fresnel来对边缘光的效果进行柔化。
最后将边缘光图用滤色叠加在整体图像上,这里注意需要剔除轮廓线,轮廓线不应受到边缘光影响。
这里要提到一个坑,视频代码中使用提取深度的函数为LinearEyeDepth(),可以直接返回距离相机的绝对距离。而在ShaderGraph中的SceneDepth节点,几个模式的输出好像和文档介绍不太一样。如果需要获取绝对深度,需要SceneDepth以Linear01模式输出,再乘以Camera的Far属性。具体内容可以参考5.难点总结部分。
视频中的做法还需要剔除描边,但是由于我这里描边是使用的模型所以这里不需要处理,最后效果如下。
在这里插入图片描述

在这里插入图片描述

(4)Matcap

Matcap也是比较经典的做法,主要是给金属材质的高光增加细节。具体做法是先取View空间的法线(可以用前面的结果再用Tranform转到View空间),这里法线XY分量的值域为(-1,1),将其映射到ScreenUV空间的(0,1),这里得到的值直接作为UV取采样Matcap贴图。
即normal.xy*0.5+0.5
这个结果就可以作为MatCap高光的效果。这个结果我拿来乘以前面的金属高光结果,再将总体混合后作为整体高光。

(5)接收阴影

到这里身体的着色器基本效果都差不多了,由于是我们自己实现的照明,所以需要自己实现接收阴影。
由于ShaderGraph中无法获取灯光的阴影贴图,我这里使用了一个自定义函数MainLight,从中读取ShadowAtten。
使用此结果对灰部颜色和暗部颜色进行插值,使得灰部的阴影部分呈现跟暗部一样的颜色,此时阴影已可正常投射到人物上,效果如下:
在这里插入图片描述

(6)自发光

虽然视频中没有但是我用的芙宁娜模型是有自发光部分的,特效发和衣服在暗处会发光。如下图游戏截图所示:
在这里插入图片描述

我看了一下在Diffuse的A通道中存储了自发光项,其值等于相对的自发光强弱。
不同于前面LightMap中G通道的1,那个是指不受光照影响恒定为BaseColor。这里的自发光部分求出之后直接加到最终结果中,在HDR的情况下可以看到自发光的色材溢出部分。
自发光项:diffuse.w*diffuse.xyz*EmissionIntensity*EmissionColor*
自发光项效果如下:
在这里插入图片描述

(7)组合输出

到这里身体部分的着色器基本上完成了,混合之前的计算结果,最后输出到Emission即可
在这里插入图片描述

在这里插入图片描述

3.脸部着色器

脸部的多数计算方法与身体几乎一样,不同的有以下几点:
1.脸部没有区分材质组,没有LightMap,所以也没有金属高光相关的贴图和参数,相应的有一张脸部的明暗图和对应的遮罩,用以处理出明暗分界线,这里的计算方式也与身体不同
在这里插入图片描述
2.脸部没有法线图
复制一个ShaderGraph,在此基础上进行修改。其实只有轮廓光和阴影能复用,重开一个也行

伦勃朗光采样

删除之前的半兰伯特光照部分,脸部采取不同的方式处理
为了使脸部总是保持最佳的伦勃朗光,对于脸部的计算,我们不考虑灯光在竖直方向上的变化,只考虑灯光在头部水平面方向上的变化,将灯光在水平方向上的角度转化为明暗阈值,随着明暗阈值的变化脸部的明暗分界线会随之推进。
下图是我做简单采这张贴图后,给出不同明暗阈值后结果的变化,可以看出随着阈值变化,贴图呈现出了阳光照在左半脸上不同阳光角度的明暗交界线
在这里插入图片描述
在知道原理后就可以着手编写了。
首先计算阳光角度,先定义一个脸部的前向量和右向量,这里我根据当前静态模型的朝向直接定义为F(0,0,1)和R(1,0,0)。后续需要用脚本将这两个参数传进来。
由此可用叉乘求出头部向上的向量U = Cross(F,R)
这里先求灯光向量L在水平面上的投影,L点乘U后可得垂直方向上的投影,再用L减去其可得水平方向上的分量,将其
LpHorizontal = L-Dot(L,U)*U/Dot(U,U)
此结果点乘R得到光线在右向量上的投影,假设光向量与右向量夹角为θ,这里求出的是cosθ
即θ = arccos(dot(Normalize(LpHorizontal) ,Normalize( R)));
这里的θ变化范围为0到Pi/2再到Pi,假设除以Pi可以得到0到0.5再到1的变化范围,此结果*2用1减,可以得到1到0再到-1的一个变化范围,仅考虑正数部分,参考上面的图,这个值和上面的阈值变化是一致的!这样我们就成功将阳光方向映射为伦勃朗光采样的阈值!
还有一些细节需要处理,比如阳光在左半边脸和右半边脸时,这个贴图应当左右反转。我们可以用上面变化范围的正负来判断,负值即为来自左半边脸,此时将U坐标oneminus即可。
还有采样结果注意还要剔除背面过来的阳光,需要乘以step(0,dot(L,F))
另外需要注意,这张贴图默认朝向是光来自左半边脸的效果,用上面的θ计算时需要注意反转。
在这里插入图片描述
这里将脸部结果直接输出,注意脸部和头发的对比,这里是不同的阈值对应的结果,例如1表示阈值为1,θ为0,阳光从正右面打过来;0表示阈值为0,θ为90度,阳光从正面打过来;
在这里插入图片描述
可以看出这里的变化率并不理想,靠近1时变化过慢,靠近0时变化过快。这里可以用常用的处理手段,将结果幂次后再比较,正好可以修正其变化率。
视频中是三次方,我这边看两次方的效果也不错,可以根据实际需求来控制这个值。
在这里插入图片描述
最后将结果先乘以遮罩,再与之前一样的混合亮部和暗部即可完成,因为脸部没有LightMap图,所以全是灰部。对于ShadowRamp,脸部按照第2行采样,按照日夜分别采样0.15和0.65的V坐标,得到暗部颜色。按照之前的方法来计算即可得到结果。
在这里插入图片描述

头部基向量传递

为什么脸部的方向一定要从外部传进来呢?因为人物是蒙皮驱动的,当头部转动时着色器没有办法获知整个脸部的朝向,只有脸部的骨骼节点是和脸部朝向强相关的。
这样会导致一个问题,我们之前写死这个方向,在人物角度变化时会出问题,比如倒立时脸部方向是反的。所以这里需要传递这个方向矢量。
找到人物头部的骨骼节点,建立两个空的子物体,一个向前方拽,一个向右方拽,这样子物体自身的位置减去父节点的位置归一化后就是我们需要的方向。
在两个子物体上挂上这个脚本,设置好需要传递的材质和变量名即可:

using UnityEngine;

[ExecuteInEditMode]
public class SendFaceVector : MonoBehaviour
{
    public string MatStringName;
    public Material[] FaceMaterials;
    private Transform FatherTrans;
    // Start is called before the first frame update
    void Start()
    {
        FatherTrans = transform.parent.GetComponent<Transform>();
    }

    // Update is called once per frame
    void Update()
    {
        Vector3 Dir =(transform.position-FatherTrans.position).normalized;
        Vector4  MyVector = new Vector4(Dir.x,Dir.y,Dir.z,0);
        for (int i=0;i<FaceMaterials.Length;i++)
        {
            FaceMaterials[i].SetVector(MatStringName,MyVector);
        }
    }
}

4最终效果

最后加上黑色描边,黑色Unlit即可,裙子从正面看过去是模型背面,把身体的渲染背面打开,此时前面轮廓光在背面裙子显示不正确,因为这部分法线是反的导致菲涅尔无法起作用,需要将这部分轮廓光剔除。如下图所示:
在这里插入图片描述

解决方法也很简单,找到view下的法线,取B通道,step到0,乘以前面的轮廓光即可。
最终效果如下图
在这里插入图片描述

4.附录:难点总结

SceneDepth节点

关于SceneDepth节点,三个模式的含义和官方文档上的介绍并不一致。我最终取深度绝对值的方法是Linear01模式乘以Camera的Far属性。
具体可以参考这篇文章Unity Shader Graph 中深度纹理(Depth Texture)和屏幕空间坐标(Screen Position)

获取阴影贴图

MainLight代码
注意使用File形式的自定义函数节点,必须在函数名声明精度,节点的精度设置也必须跟函数一致否则会报错(官方文档),设置如下:
在这里插入图片描述
自定义函数代码如下:

#ifndef CUSTOMFUNC_INCLUDE
#define CUSTOMFUNC_INCLUDE

void MainLight_half(float3 WorldPos, out half3 Direction, out half3 Color, out half DistanceAtten, out half ShadowAtten)
{
#if SHADERGRAPH_PREVIEW
   Direction = half3(0.5, 0.5, 0);
   Color = 1;
   DistanceAtten = 1;
   ShadowAtten = 1;
#else
#if SHADOWS_SCREEN
   half4 clipPos = TransformWorldToHClip(WorldPos);
   half4 shadowCoord = ComputeScreenPos(clipPos);
#else
   half4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
#endif
   Light mainLight = GetMainLight(shadowCoord);
   Direction = mainLight.direction;
   Color = mainLight.color;
   DistanceAtten = mainLight.distanceAttenuation;
   ShadowAtten = mainLight.shadowAttenuation;
#endif
}

#endif

Scene和Game视图不一致

特别在调试轮廓光的时候,出现了Scene视图和Game视图轮廓宽度完全不一样的情况,排查之后发现如果Game视图设置的分辨率是Free或者简单的16:9之类,会导致读取到的Screen长宽与Scene不一致,Scene的分辨率是以当前窗口大小为准。解决办法为设置固定的Game视图输出分辨率,或者干脆除以一个常量,使得轮廓光宽度相对于屏幕尺寸呈现一个固定的比例。

源码分享

我把项目文件导出上传了,使用URP开空白工程,安装ShaderGraph,然后导入这个包即可。
ShaderGraph的部分都尽量整理分组得比较清晰,加上了注释,需要请自取:
百度网盘:
链接:https://pan.baidu.com/s/1cW9_9Heus38mCiHUbqhcZQ
提取码:ld7p

我的Unity版本是2022.3.17f1,ShaderGraph是14.0.9。

  • 44
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值