一、一些废话
估摸着学习卡通渲染也有段时间了,就抽空撸了一个仿原神卡通渲染的Demo,这里做一个记录,也作为自己的学习笔记,最后效果上肯定没法伯分之伯还原┐( ̄ヘ ̄)┌ 但8成的还原度应该还是有的ヽ( ̄▽ ̄)ノ(大概……?)
二、场景搭建
首先康康场景是咋来的(o∀o)っ
分成两部分——天空球和带反射的地板。
1. 天空球
先说一下天空球,这边是直接做成了个预设体,其实就是个大球壳(里面为正面)上绑了个Material,还有个Animator组件,因为需要考虑到天空盒可能会旋转嘛,但其实旋转后面在Shader里实现了,这里就没用动画组件了。
天空球的Shader是直接在ASE里面实现的(毕竟连连看是真的很直观),原理很简单,整个天空球由星空(含噪声)和雾组成。
直接上图(=゜ω゜)ノ
星空部分采样贴图后先取了5次幂,压暗明部,用Max函数防止值为负,再乘上Noise遮罩,抛出来一个强度变量,最后再与原来部分相加(相当于提亮星星,同时更有层次感)。
Noise部分的处理只用了一个Panner函数,作用就是让其UV随时间变换。
雾的计算稍微复杂一丢丢,首先是这个部分。
先不管SkyFogOffset,这里其实就是计算一个上方为0,靠近底部为1的效果。
然后再加上一个SkyFogOffset的偏移,其结果呢变成一个下方为1,上方接近0,靠近顶部又变为1的效果,主要营造一种穹顶的感觉。
然后是下面的屏幕空间的计算,最后乘个系数是为了压暗,不然很容易过曝。
它最后要达到的效果就是让下面大半部分的屏幕空间为0,只有上面有一点点弧形的非0部分。
接着上面两个效果一减,当当~( • ̀ω•́ )✧
初步的效果就出来了。
当然,顶部依旧还是1哈。
其实这个已经是可以用来当遮罩了,但是我们希望雾稍微集中在地平线一些,所以就有了下面这一步,Swizzie函数可以输出多维向量中的其中一维,这里把Y输出。
取个平方可以压暗一点(也可以用pow,然后抛出来个参数自己调),还是那个问题,容易过曝……
接下来和前面的效果相乘再取反,我们就有了一个边界雾的遮罩。
然后就是混合,用线性插值就行了,最后乘个雾的颜色,星空图和噪点图网上一搜一大堆。
参数再自己调一调哈,一套组合拳下去天空球就有啦٩( 'ω' )و get!
2. 地板
好家伙,讲这么久害妹进入正题,下面就讲简单点吧。
关于地板的反射效果是用的以前白嫖的一个大佬的方案,出处忘了,只知道一直躺在我的资源文件夹里。
白嫖还理直气壮是吧( ╯-口-)╯~═╩════╩═~ 翻大型桌子
咳咳,这里首先要给地板挂一个脚本,这个脚本会抛出来一些参数,主要起一个反射效果的Manager的作用。
原理上跟镜子的制作相似,是在主相机相对于地板的对称位置new一个新Camera,再把其渲染的图像经过各种参数的处理,比如需要Blur的话,可以指定需要调用的Blur后处理的Shader,然后传入Blur的参数。
void Start() {
_sharedMaterial = GetComponent<MeshRenderer>().sharedMaterial;
if (_blurShader == null)
_blurShader = Shader.Find("Hidden/SimpleBlur");
}
Shader "Hidden/SimpleBlur"
{
Properties
{
_MainTex("", 2D) = "white" {}
}
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize;
uniform half _Offset;
half4 SimpleBlur(float2 uv, half pixelOffset)
{
half4 o = 0;
o += tex2D(_MainTex, uv + float2(pixelOffset +0.5, pixelOffset +0.5) * _MainTex_TexelSize.xy);
o += tex2D(_MainTex, uv + float2(-pixelOffset -0.5, pixelOffset +0.5) * _MainTex_TexelSize.xy);
o += tex2D(_MainTex, uv + float2(-pixelOffset -0.5, -pixelOffset -0.5) * _MainTex_TexelSize.xy);
o += tex2D(_MainTex, uv + float2(pixelOffset +0.5, -pixelOffset -0.5) * _MainTex_TexelSize.xy);
return o * 0.25;
}
half4 Frag(v2f_img i): SV_Target
{
return SimpleBlur(i.uv,_Offset);
}
ENDCG
SubShader
{
Pass
{
Cull Off ZWrite Off ZTest Always
CGPROGRAM
#pragma vertex vert_img
#pragma fragment Frag
ENDCG
}
}
}
最后再把处理好的图像以_ReflectionTex的方式传给挂载着该脚本的Object(这里就是地板)的Material,最后我们在Material的Shader里只需要计算一个以屏幕坐标采样_ReflectionTex的颜色和一个菲涅尔项就ok了。
Shader "Planar" {
Properties {
_ReflectionTex("Mirror Reflection",2D) = "white"{}
}
SubShader {
Tags {
"Queue" = "Transparent"
}
Pass {
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
uniform float4 _Color;
struct appdata {
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f {
float4 pos : SV_POSITION;
float3 posWS : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float4 posSS : TEXCOORD2;//屏幕空间坐标
};
sampler2D _ReflectionTex;
v2f vert (appdata v) {
v2f o = (v2f)0;
o.posWS = mul(unity_ObjectToWorld,v.vertex);
o.normalWS = UnityObjectToWorldNormal(v.normal);
o.pos = UnityObjectToClipPos(v.vertex);
o.posSS = o.pos;
o.posSS.y = o.posSS.y * _ProjectionParams.x;
return o;
}
half4 frag(v2f i) : SV_Target {
float3 normalWS = normalize(i.normalWS);
float3 viewDir = normalize(_WorldSpaceCameraPos - i.posWS.xyz);
float NdotV = saturate(dot(viewDir, normalWS));
float fresnel = smoothstep(0.01, 0.4, NdotV);//菲涅尔项
//平面倒影
half2 screen_uv = i.posSS.xy /i.posSS.w;
screen_uv = (screen_uv + 1.0) * 0.5;
float3 col = tex2D(_ReflectionTex, screen_uv);
return float4(col, fresnel);
}
ENDCG
}
}
FallBack "Diffuse"
}
至于这个脚本原代码放这里了,大佬们可以看看,但对于幼儿园毕业的我来说还是过于闪耀了,如果实在理解不了也没关系,就把它当成一个“黑盒子”就可以了,将它挂载到平面上,它会自动传给该平面的Material一张把它当成镜子的反射纹理。
PlanarReflection.cshttps://pan.baidu.com/s/15KX5-omwxroz6jBw5-fIOA?pwd=0mxo 最后,我们的场景基本就搭建好啦ヾ(◍°∇°◍)ノ゙
三、人物渲染
1. 罪恶装备
在正式进入原神的卡通渲染讲解之前,我们先来看看真正的卡通渲染的业界标杆——《罪恶装备》是怎么做的。
罪恶装备的渲染方案总共有四张贴图,分别是Base贴图,ILM贴图,Sss贴图和Detail贴图,部分角色会多一张Decal(贴花)贴图,用于补充一些细节,这里不赘述。
Base贴图就是基础颜色贴图,
Sss贴图就是Shadow(暗部)贴图,
ILM贴图是重点,r通道表示高光大小,b通道表示光照偏移,值越小的区域会滞后进入光照,大部分区域在0.5左右,但放在PS里其实100(0.5应该为127,难道跟在伽马空间内计算有关[・ヘ・?]),g通道表示高光的区域和大小。
还有一张Detail内描线贴图。
然后是顶点色,r通道是AO,b通道是身体部分的枚举,g通道不知道啥用,a通道表示描线宽度。
2.素材准备
然后进入正题,素材方面,可以在模之屋的原神官方下载想要的人物模型,但是得注意的是下载后的素材只有Diffuse贴图(官方也没傻到随便公开全部资源ヽ( ̄д ̄;)ノ)
但是方法总比困难多,这边给大家提供一个github上的项目,里面是解包后的原神的全部texture资源,找到你要用的人物的全部贴图即可(角色相关的贴图都在Avatar里)。
GenshinTexturehttps://github.com/umaichanuwu/GenshinLinks/blob/main/README.md 呐~全部下载下来的话素材很多,但是其实我们要用的也就我标粗来这些(当然有些也有可能是我还不知道怎么用),武器的贴图不用管。
里面有一张Cubemap是我根据模型在世界空间中的位置生成的。用到的是冯乐乐女神在入门精要里提供的工具,具体使用方法就是在Hierarchy栏右键,选择Render in Cubemap就行了,记得提前new一个Cubemap。
CubemapGenerationhttps://pan.baidu.com/s/1LsyOYd3a_CXySHcRrUptWQ?pwd=5200
然后可以给相机加上点后处理,我这里加了个抗锯齿、Bloom和一个ACES的ToneMapping,参数后面再细调。
将Lightmap,Ramp贴图的sRGB关闭,如果有法线图将其Type改成法线,因为在原神早期版本的渲染方案中是没有Normal贴图的,而后来的版本的新人物才开始使用Normal贴图,并且开始陆续更新一些老角色的Normal贴图,比如3.3版本的更新中就更新了旅行者的Normal贴图。
而我这里用的雷电将军的素材是没有Normal贴图的,有的话其实也就多一个法线解包的过程,不影响的。
3. 渲染Shader
《原神》的渲染方案和《罪恶装备》有类似的地方,但差别也不少,首先是声明变量,把要用的东西都先声明出来,里面有些看不懂没关系,后面用到的时候会讲到。
Properties
{
_BaseMap("Base Map", 2D) = "white"{}
_ILMMap("ILM Map", 2D) = "black"{}
_RampMap("Ramp Map", 2D) = "white"{}
_RampMapRow0("Ramp Map Row 0", Range(1, 5)) = 1
_RampMapRow1("Ramp Map Row 1", Range(1, 5)) = 4
_RampMapRow2("Ramp Map Row 2", Range(1, 5)) = 3
_RampMapRow3("Ramp Map Row 3", Range(1, 5)) = 5
_RampMapRow4("Ramp Map Row 4", Range(1, 5)) = 2
_DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
_ShadowIntensity("Shadow Intensity", Range(0, 1)) = 1
_ShaodwColor("Shadow Color", Color) = (1, 1, 1, 1)
_ToonMap("Toon Map", 2D) = "white"{}
_ToonColorIntensity("Toon Map Intensity", Range(0, 1)) = 0.5
_MetallicMap("Metallic Map", 2D) = "black"{}
_MetallicIntensity("Metallic Intensity", Float) = 1
_HairSpecMap("Hair Spec Map", 2D) = "black"{}
_HairSpecIntensity("Hair Spec Intensity", Float) = 1
_SpecColor("Spec Color", Color) = (1, 1, 1, 1)
_SpecSize("Spec Size", Range(0, 1)) = 0.1
_MetallicSpecIntensity("Metallic Spec Intensity", Float) = 1
_NonMetallicSpecIntensity("Non Metallic Spec Intensity", Float) = 1
_EnvironmentMap("Environment Map", Cube) = "white"{}
_EnvironmentIntensity("Environment Intensity", Float) = 1
_Roughness("Roughness", Range(0, 1)) = 0
_FresnelMin("Fresnel Min", Range( -1, 0.5)) = 0
_FresnelMax("Fresnel Max", Range(0.5, 2)) = 1
_RimColor("Rim Color", Color) = (1, 1, 1, 1)
_OutlineWidth("Outline Width", Float) = 7.0
_OutlineZOffset("Outline Z Offset", Float) = -10
_OutlineColor("Outline Color", Color) = (1, 1, 1, 1)
_IsDay("Is Day", Range(0, 1)) = 1
}
然后是计算阴影的Pass,不过这里其实可以省略,只要你Fallback的不是Transparent,那么Shadow Pass都是包含在里面的,我这里只是个人习惯而已。
Pass
{
Name"ShadowCaster"
Tags{"LightMode" = "ShadowCaster"}
ZWrite On
ZTest LEqual
ColorMask 0
Cull off
CGPROGRAM
ENDCG
}
该包含的关键字和依赖包都声明一下,光照计算采用前向渲染,至于为什么要开双面显示呢,原因是原神中很多布料都是单层面片,卡掉任何一面都会引起显示Bug。
Tags{"LightMode" = "ForwardBase"}
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#pragma multi_compile_fog//启用雾
#include "UnityCG.cginc"
#include "AutoLight.cginc"
#include "Lighting.cginc"
顶点着色器和片元着色器的输入。
struct appdata
{
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float3 worldNormal : TEXCOORD2;
float4 vertexColor : TEXCOORD3;
UNITY_FOG_COORDS(4)
SHADOW_COORDS(5)
};
顶点着色器很简单,只需要处理一下坐标和法线、雾和阴影坐标就行了,剩下的活都交给片元着色器。
虽然应该大部分人都知道,不过这里还是简单提一下,如果有法线的话是需要在Properties里多一个Normal贴图的声明的,并且顶点着色器需要传一个TBN矩阵给片元着色器,然后在片元着色器里用UnpackNormal采样法线,再把它从切线空间转换到世界空间,我这里由于没有Normal贴图,所以就省去了。
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.uv = v.texcoord0;
o.vertexColor = v.color;
//计算雾与阴影坐标
UNITY_TRANSFER_FOG(o, o.vertex);
TRANSFER_SHADOW(o)
return o;
}
下面就是重头戏啦ψ(・ω´・,,ψ~
片元着色器开始先把向量和点乘全家桶算一下,下面要用就可以直接拿。
//N, L, V, H向量
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
half3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
half3 H = normalize(worldNormal + worldViewDir);
half NdotL = dot(worldNormal, worldLightDir);
half NdotH = dot(worldNormal, H);
half NdotV = dot(worldNormal, worldViewDir);
然后采样Base贴图。
//Base贴图
half3 baseColor = tex2D(_BaseMap, i.uv).rgb;
采样Lightmap,并且把每个通道的信息单独提取出来,注意这里的a通道储存的是材质的类型,不同的类型在后面采样Ramp贴图时对应不同的行。
//ILM贴图
fixed4 ilmMap = tex2D(_ILMMap, i.uv);
fixed specIntensity = ilmMap.r;//高光强度
fixed lightOffset = ilmMap.g;//光照偏移量
fixed sepcSize = ilmMap.b;//高光形状大小
fixed matEnum = ilmMap.a;//材质枚举
我们可以分别把Lightmap的rgba通道分别输出看一下。




好奇的同学应该已经发现了,为什么脸部木有材质捏゛(‘◇’)?
其实原神的人物模型大部分都分成5个材质(部分人物有6个),分别对应衣服、头发、皮肤、面部、头发高光。那按理来说应该是至少需要3个Shader的(衣服、头发和皮肤一个,面部一个、头发高光一个),但头发高光那个Shader其实就是个贴花Shader直接贴上去的,这样可调节性比较差,所以这里我们就把它集成到衣、发、肌的Shader中。
下面用Matcap的方式采样Toon贴图,将视点空间下法线的xy值映射到0到1的区间,用来作为采样Toon贴图的uv坐标。
//Toon贴图
half3 viewSpaceNormal = normalize(mul((float3x3)UNITY_MATRIX_V, worldNormal));//视点空间法线
half2 uvMatCap = viewSpaceNormal.xy * 0.5 + 0.5;
half3 toonColor = tex2D(_ToonMap, uvMatCap).rgb;
接着采样金属度贴图,由于我们只需要灰度值,所以这里采样单通道就行了。
//Metallic贴图
fixed metallicIntensity = tex2D(_MetallicMap, uvMatCap).r;
到此为止,各种基本贴图的采样工作就完成了,下面开始光照计算,首先用刚刚采样的ToonColor和原颜色进行插值,做一个底部体积光照的效果。
//底部体积光照
baseColor = lerp(baseColor, baseColor * toonColor, _ToonColorIntensity);
下面开始计算采样Ramp贴图的V坐标,首先得明确刚才提到的Lightmap的a通道储存有材质的枚举信息,具体点就是0、0.3、0.5、0.7、1.0分别对应不同材质。
而Ramp贴图v方向有20个像素,共10行,上5行为白天,下五行为夜晚(body和hair各有一张Ramp,这里拿body举例),而Ramp贴图上记录的就是不同材质明暗部的颜色倾向。
//ILM贴图的a通道存有5种材质,分别对应0, 0.3, 0.5, 0.7, 1.0
fixed matEnum1 = 0.3;
fixed matEnum2 = 0.5;
fixed matEnum3 = 0.7;
fixed matEnum4 = 0.9;
//Ramp贴图V方向有20个像素,共10行(上5行为白天,下五行为夜晚)
//每行对应2个像素,这里将rampV限制在0到0.5( - 0.05是为了让rampV对应在该行的两个像素中间)
float rampV0 = _RampMapRow0 / 10.0 - 0.05;
float rampV1 = _RampMapRow1 / 10.0 - 0.05;
float rampV2 = _RampMapRow2 / 10.0 - 0.05;
float rampV3 = _RampMapRow3 / 10.0 - 0.05;
float rampV4 = _RampMapRow4 / 10.0 - 0.05;
//将该片元的matEnum与matEnum1到4分别比较(0没有声明,也不用比较), 来确定该片元的rampV
fixed dayRampV = lerp(rampV4, rampV3, step(matEnum, matEnum4));
dayRampV = lerp(dayRampV, rampV2, step(matEnum, matEnum3));
dayRampV = lerp(dayRampV, rampV1, step(matEnum, matEnum2));
dayRampV = lerp(dayRampV, rampV0, step(matEnum, matEnum1));
fixed nightRampV = dayRampV + 0.5;
然后就可以开始计算漫反射了,先搞定兰伯特和半兰伯特项,半兰伯特取2次幂是为了更平滑(当然也可以不取)。然后算出一个step项用来做后面光照的阶梯化。
smoothstep(Min,Max,x)函数就是传入的x小于Min就直接返回0,大于Max就直接返回1,如果在Min和Max之间就会以一个“s”形的曲线在0到1平滑过渡,如果Min和Max相等,那就和step函数是一个效果。
//漫反射
//兰伯特、半兰伯特
fixed lambert = saturate(NdotL);
fixed halfLambert = pow(lambert * 0.5 + 0.5, 2);
fixed halfLambertStep = smoothstep(0.423, 0.460, halfLambert);
然后终于可以采样Ramp贴图了,用刚才计算的半兰伯特项做一个0.2到0.4之间的smoothstep,把它钳制在0到1之间,作为采样的u坐标。
由于我们上面在计算v坐标时是把左上角当成原点,但实际的纹理采样的原点是左下角,所以这里要做一个v坐标取反的操作。
用_IsDay判断白天还是夜晚得到RampColor,再和baseColor做一个插值即可得到漫反射阴影部分的最终颜色。
//计算RampUV,采样Ramp贴图
fixed rampU = clamp(smoothstep(0.2, 0.4, halfLambert), 0.005, 0.995);//阴影部分中的深浅是在0.2到0.4之间过渡
fixed2 dayRampUV = fixed2(rampU, 1 - dayRampV);
fixed2 nightRampUV = fixed2(rampU, 1 - nightRampV);
half3 rampColor = lerp(tex2D(_RampMap, nightRampUV).rgb, tex2D(_RampMap, dayRampUV).rgb, _IsDay);//利用_isDay插值白天与黑夜的阴影颜色
half3 shadowColor = lerp(baseColor, baseColor * rampColor, _ShadowIntensity) * _ShaodwColor.rgb;
然后综合一下,用半兰伯特step项插值原颜色和阴影颜色,得到diffuse,前面说了,我们还得考虑光照偏移项,最后加上一个自定义颜色项,方便微调。
//计算漫反射项
half3 diffuse = lerp(shadowColor, baseColor, halfLambertStep);//明部到阴影是在0.423到0.460之间过渡的
diffuse = lerp(shadowColor, diffuse, saturate(lightOffset * 2));//将ILM贴图的g通道乘2 用saturate函数将超过1的部分去掉,混合常暗区域(AO)
diffuse = lerp(diffuse, baseColor, saturate(lightOffset - 0.5) * 2);//将ILM贴图的g通道减0.5乘2 用saturate函数将小于0的部分去掉,混合常亮部分(眼睛)
diffuse = diffuse + diffuse * _DiffuseColor.rgb;
我们来看看结果(参数可以自己调一调),其实初步的效果已经有了。
而且细节部分也可以看到,过渡还是比较平滑的。
接下来是镜面反射的部分,先算出布林冯项,取_SpecSize(前面的Lightmap的b通道)次幂后再判断一下光照方向,NdotL小于0就不计算高光了。
然后采样一下头发高光的贴图,算出头发高光项。
这里非金属部分和金属部分分开算,先布林冯取反,再用step函数和高光区域做判断,再乘上高光强度,这一步可以理解为:specSize或是specIntensity中为0的部分不存在非金属高光,而有非金属高光的部分不论是否被直接光照射,均会存在一定程度的非金属高光,最后可以加上头发高光项。
金属部分的高光则只有被直接光照射到的区域(并且specSize和specIntensity均要非0)才会有,就直接常规那几项乘起来就行。
然后做个金属度的判断,由于没有金属度贴图,我们就把Lightmap的r通道当成F0来算个金属度,然后用金属度来决定用上面哪种镜面反射。
//高光反射
half blinnPhong = step(0, NdotL) * pow(max(NdotH, 0), _SpecSize);
half3 hairSpec = tex2D(_HairSpecMap, i.uv).rgb * blinnPhong * _HairSpecIntensity * baseColor;
half3 nonMetallicSpec = step(1.01 - blinnPhong, sepcSize) * specIntensity * _NonMetallicSpecIntensity + hairSpec;//BlinnPhong取反做step用来限制非金属高光的区域
half3 metallicSpec = blinnPhong * sepcSize * halfLambertStep * baseColor * _MetallicSpecIntensity;
//计算金属、高光项
fixed isMetal = step(0.995, specIntensity);
half3 specular = lerp(nonMetallicSpec, metallicSpec, isMetal) * _SpecColor.rgb;
half3 metallic = lerp(0, metallicIntensity, isMetal) * baseColor * _MetallicIntensity;
//混合
half3 finalColor = diffuse + specular + metallic;
最后加个金属高光项,这个是前面用Matcap的方式采样的,严格来讲这其实不算是所谓的金属度贴图,而是一种补光。
混合后我们再来康一康,这个效果细节上就会更丰富一些了。
可以留意一下头发和金属部分的高光细节。
GenshinDemo Diffuse+Specular
然后我们再来用前面生成的Cubemap来做一些边缘光。
首先算出菲涅尔项,再求出反射方向。
接着处理粗糙度,这里要注意,我们希望抛出来的调参用的粗糙度(即_Roughness)是线性的,但在实际计算中需要用到的roughness其实是非线性的,所以要多一个转换的操作,
然后用roughness求出mipmap的层级,层级越高,越模糊,然后就是采样Cubemap,解码HDR,再乘上菲涅尔项等等一些常规操作了。
//边缘环境反射光
fixed fresnel = 1.0 - dot(worldNormal, worldViewDir);
fresnel = lerp(fresnel, 2.0 - fresnel, step(1, fresnel));//由于开启了双面显示,当显示出来的面片为背面时,需要取2.0 - fresnel
fresnel = smoothstep(_FresnelMin, _FresnelMax, fresnel);
fixed3 reflectDir = reflect( - worldViewDir, worldNormal);//反射光线方向
//环境反射粗糙度
float roughness = lerp(0.0, 0.95, saturate(_Roughness));
roughness = roughness * (1.7 - 0.7 * roughness);
float mipLevel = roughness * 6.0;
//EnvironmentHDR贴图
half4 cubemapColor = texCUBElod(_EnvironmentMap, float4(reflectDir, mipLevel));
half3 environmentColor = DecodeHDR(cubemapColor, _EnvironmentMap_HDR);
half3 environment = environmentColor * fresnel * _EnvironmentIntensity;
finalColor = finalColor + environment;
我这里值调得比较大,主要是方便大家看得清楚,但事实上不需要这么强哈,不然有点喧宾夺主了,稍微意思意思一下就可以了。
最后混合一下雾效和阴影就大功告成啦 ̄ω ̄=
//混合雾效
UNITY_APPLY_FOG(i.fogcoord, finalColor);
//混合阴影
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return half4(finalColor * atten, 1.0);
基本效果展示~
近处也可以看到边缘环境光的效果。
这里提一嘴,不论是边缘环境光还是高光,都尽量用得克制一点,不要都把参数调得很高,所有效果一股脑儿往上怼,这样会显得很杂,我们尽量让人物干净一点,效果清爽一点。
然后观察仔细的靓仔会发现其实我已经加了描边了,这里使用的是法线外扩的方式来做的描边,这种方法有个小缺点就是在过渡比较剧烈的时候描边可能产生断裂,不过优点嘛……就是简单。另一种切线外扩的方法可以有效解决断裂的问题,但是会比较麻烦,留给大家自己去探索啦(绝对不是我懒o(* ̄3 ̄)o )。
//外描线
pass{
Tags{"LightMode" = "ForwardBase"}
Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata{
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR;
};
struct v2f{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 vertexColor : TEXCOORD3;
};
sampler2D _BaseMap;
sampler2D _ILMMap;
float _OutlineWidth;
float _OutlineZOffset;
fixed4 _OutlineColor;
v2f vert(appdata v){
v2f o;
float3 viewPos = UnityObjectToViewPos(v.vertex);
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 outlineDir = normalize(mul((float3x3)UNITY_MATRIX_V, worldNormal));//将法线从世界空间转换到视点空间,作为法线外扩的方向
outlineDir.z = _OutlineZOffset * (1.0 - v.color.b);//顶点g通道控制该顶点外扩方向的z(深度)偏移
viewPos += outlineDir * _OutlineWidth * 0.001 * v.color.a;//顶点a通道控制该顶点描边宽度
o.pos = mul(UNITY_MATRIX_P, float4(viewPos, 1.0));
o.uv = v.texcoord0;
o.vertexColor = v.color;
return o;
}
fixed4 frag(v2f i) : SV_TARGET{
fixed3 baseColor = tex2D(_BaseMap, i.uv).xyz;
//一段神秘的代码, 可以让描边根据该部位的颜色和面板中的颜色产生一定的颜色偏移和混合
half maxComponent = max(max(baseColor.r, baseColor.g), baseColor.b) - 0.004;
half3 saturatedColor = step(maxComponent.rrr, baseColor) * baseColor;
saturatedColor = lerp(baseColor, saturatedColor, 0.6);
half3 outlineColor = 0.8 * saturatedColor * baseColor * _OutlineColor.xyz;
return fixed4(outlineColor, 1.0);
}
ENDCG
}
4. SDF面部阴影
如果我们直接用模型法线去计算面部阴影的话会显得很脏,very地不nice(▼へ▼メ)
《罪恶装备》的做法是让美术人员一根一根地手动去调整法线,再用调整后的法线进行光照计算。
看上去似乎还行?但这种做法其实有一个弊端,那就是只有当光与模型正前方夹角不是很大的时候有效果,如果光从掠射角过来仍然会穿帮,而且法线调起来工程量很大,又是细活,但是由于《罪恶装备》是横版格斗游戏,摄像机和入射光的角度都是固定的,所以这种方法能实施,但对于《原神》这种自由视角的开放世界游戏来说就不适用了,因此《原神》使用SDF距离场生成了一张面部阴影的阈值图,并以此来计算面部阴影。
然后我们来写面部的Shader,这边可以直接复制上面的Shader,然后把不需要的部分删去,这里Ramp贴图我们只需要采样一行就够了,再添加一张SDF贴图。
Properties
{
_BaseMap("Base Map", 2D) = "white"{}
_RampMap("Ramp Map", 2D) = "white"{}
_ToonMap("Toon Map", 2D) = "white"{}
_SDFMap("SDF Map", 2D) = "white"{}
_DiffuseColor("Diffuse Color", Color) = (1, 1, 1, 1)
_SpecColor("Spec Color", Color) = (1, 1, 1, 1)
_SpecSize("Spec Size", Range(0, 1)) = 0.1
_FaceSpecIntensity("Face Spec Intensity", Float) = 1
_OutlineWidth("Outline Width", Float) = 7.0
_OutlineZOffset("Outline Z Offset", Float) = -10
_OutlineColor("Outline Color", Color) = (1, 1, 1, 1)
_RampMapRow("Ramp Map Row", Range(1, 5)) = 1
_ShadowIntensity("Shadow Intensity", Range(0, 1)) = 1
_ShaodwColor("Shadow Color", Color) = (1, 1, 1, 1)
_ToonColorIntensity("Toon Map Intensity", Range(0, 1)) = 0.5
_IsDay("Is Day", Range(0, 1)) = 1
_FresnelMin("Fresnel Min", Range( -1, 0.5)) = 0
_FresnelMax("Fresnel Max", Range(0.5, 2)) = 1
_RimColor("Rim Color", Color) = (1, 1, 1, 1)
}
其他地方的操作都一样,不过记得片元着色器的输入记得加上模型在世界空间下的前、上、右向量,这在后面计算SDF的时候会用到,其实在这里包括上面的Shader中vertexColor都没怎么用到,我习惯会用顶点色去修改一些效果(比如控制不同部分描边的宽度),这纯属个人条件反射给加上去了,不想用可以忽略。
struct appdata
{
float4 vertex : POSITION;
float2 texcoord0 : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float3 worldNormal : TEXCOORD2;
float4 vertexColor : TEXCOORD3;
float3 upDir : TEXCOORD4;
float3 rightDir : TEXCOORD5;
float3 forwardDir : TEXCOORD6;
UNITY_FOG_COORDS(7)
SHADOW_COORDS(8)
};
顶点着色器,都是基操,就不多赘述了。
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.upDir = UnityObjectToWorldDir(float3(0, 1, 0));
o.rightDir = UnityObjectToWorldDir(float3(1, 0, 0));
o.forwardDir = UnityObjectToWorldDir(float3(0, 0, 1));
o.uv = v.texcoord0;
o.vertexColor = v.color;
UNITY_TRANSFER_FOG(o, o.vertex);
TRANSFER_SHADOW(o)
return o;
}
片元着色器,一样的全家桶,一样的漫反射,不过这里记得Ramp贴图只用采样皮肤那一行就行了。
//N, L, V, H, Up, Right, Forward向量
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
half3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
half3 H = normalize(worldNormal + worldViewDir);
half NdotL = dot(worldNormal, worldLightDir);
half NdotH = dot(worldNormal, H);
half NdotV = dot(worldNormal, worldViewDir);
half3 upDir = normalize(i.upDir);
half3 rightDir = normalize(i.rightDir);
half3 forwardDir = normalize(i.forwardDir);
//Base贴图
half3 baseColor = tex2D(_BaseMap, i.uv).rgb;
//Toon贴图
half3 viewSpaceNormal = normalize(mul((float3x3)UNITY_MATRIX_V, worldNormal));//视点空间法线
half2 uvMatCap = viewSpaceNormal.xy * 0.5 + 0.5;
half3 toonColor = tex2D(_ToonMap, uvMatCap).rgb;
//底部光照体积
baseColor = lerp(baseColor, baseColor * toonColor, _ToonColorIntensity);
//Ramp贴图V方向有20个像素,共10行(上5行为白天,下五行为夜晚)
//每行对应2个像素,这里将rampV限制在0到0.5(-0.05是为了让rampV对应在该行的两个像素中间)
float rampV = _RampMapRow/10.0 - 0.05;
fixed dayRampV = rampV;
fixed nightRampV = dayRampV + 0.5;
//漫反射
fixed lambert = saturate(NdotL);
fixed halfLambert = pow(lambert * 0.5 + 0.5, 2);
fixed halfLambertStep = smoothstep(0.423, 0.460, halfLambert);
fixed rampU = clamp(smoothstep(0.2, 0.4, halfLambert), 0.005, 0.995);//阴影部分中的深浅是在0.2到0.4之间过渡
fixed2 dayRampUV = fixed2(rampU, 1 - dayRampV);
fixed2 nightRampUV = fixed2(rampU, 1 - nightRampV);
half3 rampColor = lerp(tex2D(_RampMap, nightRampUV).rgb, tex2D(_RampMap, dayRampUV).rgb, _IsDay);//利用_isDay插值白天与黑夜的阴影颜色
half3 shadowColor = lerp(baseColor, baseColor * rampColor, _ShadowIntensity) * _ShaodwColor.rgb;
终于到SDF面部阴影了,这块代码不复杂,主要是要把这个原理搞懂,可能会有点绕,我们来理一遍。
首先我们需要求出光源向量在向上向量上的投影向量,再用光源向量减去它,就可以得到光源向量的水平分量。
然后我们求出该水平分量与模型右向量的夹角(0到π),除以π(0到1),然后判断lightAngle是否小于0.5,如果lightAngle在0到0.5之间,光源就在右边,如果lightAngle在0.5到1之间,光源就在左边。
我们这里假设光在右边,从右边逐渐变换到正前方,那么和右向量的夹角从0逐渐变化到π/2,除以π,即lightAngle从0逐渐变换到0.5,isRight就为1,接着乘2(从0到1),再取反(从1到0),也就是说当光源从右边逐渐变换到正前方的时候,lightArea是从1逐渐变为0的。
然后采样SDF贴图,由于此时光在右边,而默认的SDF贴图时按照光在左边时的情况来计算阈值的,所以光在右边的情况下,采样时需要将u坐标取反,得到采样结果sdfShadow。
接着用前面算好的lightArea和sdfShadow来做一个可见度测试,只有采样的sdfShadow中大于lightArea的部分才能通过测试(也就是光能照射到,sdfTest为1),而小于lightArea的部分无法通过测试(也就是在阴影中,sdfTest为0),过后还得判断一下朝向,如果光从后面射过来,那就都无法通过测试。
按照我们前面的假设,光源从右边逐渐变换到正前方,lightArea是从1逐渐变为0的,也就是说通过测试的像素会越来越多,结果就是能被光照到的部分会逐渐增大,这符合我们的假设。
最后再用sdfTest将明部和暗部的颜色插值,就得到SDF面部阴影了。
//SDF面部阴影
half3 LpU = dot(worldLightDir, upDir) * upDir/pow(length(upDir), 2);//光源向量在向上向量上的投影向量
half3 lightHorizon = worldLightDir - LpU;//光源向量水平方向上的分量
float pi = 3.141592654;
float lightAngle = acos(dot(normalize(lightHorizon), rightDir))/pi;//算出光源向量与模型右向量的夹角,并除以pi映射到0到1之间
fixed isRight = step(lightAngle, 0.5);//lightAngle在0到0.5区间时,lightAngle小于90度,即光从右边照过来
//lightAngle乘2将半张脸重映射到0到1之间
//取反是为了让lightAngle从0到1(0度到90度)变化时,lightArea能从1变化到0(右半脸是这样,左边脸刚好反过来,lightAngle从1到0,lightArea从0到1)
//这样SDF贴图中能通过光照区域测试的部分会逐渐增大,面部明部就会逐渐增大
float lightAreaR = pow(1 - lightAngle * 2, 3);
float lightAreaL = pow(lightAngle * 2 - 1, 3);
float lightArea = lerp(lightAreaL, lightAreaR, isRight);
//SDF贴图
half sdfShadowR = tex2D(_SDFMap, half2(1 - i.uv.x, i.uv.y)).r;
half sdfShadowL = tex2D(_SDFMap, i.uv).r;
half sdfShadow = lerp(sdfShadowL, sdfShadowR, isRight);
//光照区域测试,只有采样的SDF值大于lightArea值,才是明部
fixed sdfTest = step(lightArea, sdfShadow);
sdfTest = lerp(0, sdfTest, step(0, dot(normalize(lightHorizon), forwardDir)));//判断朝向,放置后脑勺采样SDF
float3 diffuse = lerp(shadowColor, baseColor, sdfTest);
diffuse = diffuse + diffuse * _DiffuseColor;
至于高光和边缘光就不搞那些花里胡哨的了,尽量写简单,因为卡通渲染人物面部最重要的就是干净!干净!干净!重要的事情说三遍。
//高光反射
half blinnPhong = step(0, NdotL) * pow(max(NdotH, 0), _SpecSize);
half3 specular = blinnPhong * baseColor * _FaceSpecIntensity * _SpecColor;
//混合
half3 finalColor = diffuse + specular;
//边缘光
fixed fresnel = 1.0 - dot(worldNormal, worldViewDir);
fresnel = smoothstep(_FresnelMin, _FresnelMax, fresnel);
half3 rim = fresnel * baseColor * _RimColor;
最后加上雾效和阴影就ok惹_(:3⌒゙)_
//混合边缘光
finalColor = 1 - (1 - rim * fresnel) * (1 - finalColor);
//混合雾效
UNITY_APPLY_FOG(i.fogcoord, finalColor);
//混合阴影
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return half4(finalColor * atten, 1.0);
来看看效果,very nice!
GenshinDemo FinalColor
四、物理骨骼
1. UnityChanSpringBone
就我所知,原神使用的模拟物理骨骼效果的插件是UnityChanSpringBone,这个插件需要将参与计算的骨骼都添加脚本,可调的参数也较少,我自己测试的时候觉得整体模拟得并不真实,效果上是有点僵硬的,碰撞体甚至不能偏移位置,因此穿模严重,但是它在计算力的消耗上非常省,极其便宜,这大概就是mhy选择它的原因,但《原神》实际的做法应该是实时物理模拟加部分动画K帧的模式,这样能有效减少穿模,但对于幼儿园毕业的我来说,这还是太技术活了(好吧我就是懒),所以下面介绍另外两个物理骨骼的插件。
2. MagicaCloth 2
一个是MagicaCloth 2,我用的2代,和1代相比功能上差别不大,主要是一些算法上的优化,而且可以加风场、力场什么的,可玩性很高。这个插件可以说占据MMD的半壁江山,主要是它的布料模拟效果真的很好,而且防穿模做得挺不错,当然,算力消耗上会有点小贵,不过我们这只是做个效果展示的Demo,所以无所谓。
3. DynamicBone
另一个是DynamicBone,这个也算是Unity商店很有名的一个插件了,他的用法和MagicaCloth 2差不多,模拟度还行,资源消耗也可以接受,主要是MagicaCloth 2模拟出来的效果有点太软了,太飘了(当然也可能是我的参数没有调好),需要模拟一些比较有弹性的物体的时候不是很好用,但DynamicBone刚好可以解决这个烦恼,所以我就用它来模拟了一些挂饰的抖动和乳摇的效果(嘿嘿o(*▽*)q)。
4. 学习资料
呐……这只是我的见解,或许你能有更好的解决办法,反正3个插件我都放这儿了,大家自己拿去玩儿吧,具体咋用网上教程很多,一搜就能搜到(要用进项目记得去Unity商店买正版哈,这里仅限学习使用)。
UnityChanSpringBonehttps://pan.baidu.com/s/1JG2RNS7S7-ReOlXsjigXeQ?pwd=njt8DynamicBone
https://pan.baidu.com/s/1VoFZXtH5IdArSOB2T3KgjA?pwd=zfkdMagicaCloth 2
https://pan.baidu.com/s/1n6k_lZW0w_RlOjJWvKYYKg?pwd=ltt6 我这里多加了一个Magica的风场,默认是关闭的,用按钮控制开关,主要是像跑步动画这种浮动比较大的动画,让衣服和头发飘起来能减少穿模的概率。
然后关于动画资源,直接去Mixamo上找就行了,下载时记得连模型一起下载,如果只下载动画的话导入引擎后骨骼可能会出错(会扭向奇怪的角度),不好做重定向。
UI的话我就直接套用了这个插件的框架。
(▰˘◡˘▰) <==你也太懒了吧喂!╰o(▼皿▼メ;)o
五、成品展示
最后欣赏一下我们费了九牛二虎之力做出来的Demo,快奖励自己一朵小红发❀~
GenshinDemo
参考文章
西川善司「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密