NPR卡通渲染

在UNITY商店下了个免费的琥珀酱的model(好像叫UNITY CHAN,有兴趣的可以自己下载玩玩),发现作者已经写了几个shader,效果看起来还不错,不过不太完全,试着自己补一补

这是用标准着色器的效果,光照信息多了之后真是丑的不行……咱改改。不用bli-phong光照模型,用我们卡通逻辑的光照模型。

第一版shader

Shader "UnityChan-Self/Clohting" {
	Properties {
		_MainTex ("Diffuse", 2D)= "white" {} //albedo材质,定义底色
		_FallOffSampler ("Falloff Control", 2D) = "white" {} //光照衰减取样
		_FALLOFF_POWER ("Falloff Power", Float) = 0.3 //控制光照衰减取样强度
	}

	SubShader {
		Tags {
			"RenderType" = "Opaque"		
			"Queue" = "Geometry"
			"LightMode" = "ForwardBase"
		}

		Pass {
			Cull Back
			ZTest LEqual

			CGPROGRAM
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			#include "AutoLight.cginc"

			sampler2D _MainTex;
			float4 _MainTex_ST;
			sampler2D _FallOffSampler;
			float _FALLOFF_POWER;

			struct v2f {
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
				float3 viewDir : TEXCOORD1;
				float3 normal : TEXCOORD2;
				float3 tangent : TEXCOORD3;
				float3 binormal : TEXCOORD4;
				float3 lightDir : TEXCOORD5;
			};

			v2f vert(appdata_tan v) {
				v2f o;

				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv.xy = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
				//_Object2World是针对四维向量的,如果要用法线直接乘,需要在后面补一个0,我们就直接用内置函数算了
				//o.normal = mul(_Object2World, v.normal).xyz; 
				o.normal = UnityObjectToWorldNormal(v.normal);
				
				half4 worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.viewDir.xyz = normalize(_WorldSpaceCameraPos.xyz - worldPos.xyz).xyz;
				//得到世界空间视线方向

				o.tangent = v.tangent.xyz;
				o.binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
				//副切线在切线空间里

				o.lightDir = WorldSpaceLightDir(v.vertex);

				return o;
			}

			float4 frag(v2f i) : SV_Target {
				float4 diffuseSamplerColor = tex2D(_MainTex, i.uv.xy); //取材质色

				float3 normalVec = i.normal;
				float normalDotEye = dot(i.normal, i.viewDir.xyz); //视线和法线的点乘
				float fallOffU = clamp(1.0 - abs(normalDotEye), 0.02, 0.98); //点乘取反

				float4 fallOffSamplerColor = _FALLOFF_POWER * tex2D(_FallOffSampler, float2(fallOffU, 0.25f)); //取颜色衰减
				float3 shadowColor = diffuseSamplerColor.rgb * diffuseSamplerColor.rgb; //将材质色平方,得到一个深色
				float3 combinedColor = lerp(diffuseSamplerColor.rgb, shadowColor, fallOffSamplerColor.r); //根据颜色衰减,取材质色到深色的中间色
				combinedColor *= (1.0 + fallOffSamplerColor.rgb * fallOffSamplerColor.a); //一定程度补偿深色变亮

				return float4(combinedColor.rgb, 1.0);
			}

			ENDCG
		}


	}

	FallBack "Transparent/Cutout/Diffuse"
}

我们不用viewDir和lightDir的dot来计算光照效果,而是直接用viewDir和normal的点乘,这就有点像随时有个光源跟随着摄像机移动,减少光照的细节度,增加底层颜色的突出程度


直接把点乘结果输出的效果,有点像菲涅尔反射2333,不过是简化版本的。越白的地方意味着光照衰减取样越靠右

加上我们画好的光照衰减取样图,越左数值越靠近(0,0,0),越右越靠近(1,1,1),中间的渐变是日式卡通的特点,一般会有一个过渡色而不是硬过渡。说个题外话,实际上存储这个信息,只需要rgba四通道里的任意一个(我们实际上只需要一个0-1的float而不是现在的Vector3),所以可以往里面继续塞来存储其他信息

和底色混合后(0取原底色,1取原底色的平方),效果是这样的

其实讲究的日式卡通渲染应该是有两张底色的,一张作为“明”,一张作为“阴”,就是底色和阴影两张,不过我们没有,就直接把底色的开平方当做阴影色了。(有些更讲究的卡通渲染用的是3张底色图甚至4张)

感觉阴的颜色有点过了,用FALLOFF_POWER把阴的地方的数值稍微压一压,再加数值,做一定的亮度补偿,看自己感觉调整吧,毕竟NPR就是个 很主观的玄学玩意儿……

最后效果如图


然后做一个高光,依然是无视光源方向,把摄像机方向当做光源方向来运算

代码在fragment里接着上述代码

		float4 reflectionMaskColor = tex2D(_SpecularReflectionSampler, i.uv.xy); //我也不太清楚这张高光图怎么来的
		float specularDot = normalDotEye; //在真实系渲染里,这个值应该是Normal dot H向量(光照 + 视线的法向量,一般用H表示)
						  //这里把光照当做视线,所以H就是视线向量,Normal dot 视线向量就是上面求过的normalDotEye
		float4 lighting = lit(normalDotEye, specularDot, _SpecularPower); //CG的内置函数
		float3 specularColor = saturate(lighting.z) * reflectionMaskColor.rgb * diffuseSamplerColor.rgb;
		combinedColor += specularColor; //add混合,只要是做加法,那肯定是变亮

lit函数的作用如下:

lit(NdotL, NdotH, m)
(dot = 点乘)
N表示法向量;
L表示入射光向量;
H表示半角向量;
m表示高光系数。
函数计算环境光、散射光、镜面光的贡献,返回的4元向量。
X位表示环境光的贡献,总是1.0;
Y位代表散射光的贡献,如果 NdotL<0,则为0;否则为NdotL
Z位代表镜面光的贡献,如果NdotL<0 或者NdotH<0,则位0;否则为(NdotH)^m;
W位始终位1.0

其实就是Blinn-Phong光照模型,不过内置了之后性能会高一点

模型的高光图是这样婶的

不是很懂怎么来的,感觉就是在一些边缘的地方加强了高光的反射,可能是指衣服的金属,塑料边缘之类的

最终效果(等等啊,为什么背心会这么反光啊,牛皮吗这是?)


原本作者还有一个环境反射的计算,不过我看了看,他是要做AR才需要写这个东西。我就不在这写了,直接到阴影投射

阴影投射其实是光衰减计算,这里考虑的就是世界空间的光源(不然还是像上面那样光源跟随摄像机还看个鬼的阴影)。很简单的计算,用内置函数就可以搞定了。我把整个代码再贴一次

第二版shader

Shader "UnityChan-Self/Clohting" {
	Properties {
		_MainTex ("Diffuse", 2D)= "white" {} //albedo材质,定义底色
		_ShadowColor ("Shadow Color", Color ) = (0.8, 0.8, 1, 1)
		_FallOffSampler ("Falloff Control", 2D) = "white" {} //光照衰减取样
		_FALLOFF_POWER ("Falloff Power", Float) = 0.3 //控制光照衰减取样强度
		_SpecularReflectionSampler ("Specular Control", 2D) = "white" {}
		_SpecularPower ("Specular Power", Float) = 1
	}

	SubShader {
		Tags {
			"RenderType" = "Opaque"		
			"Queue" = "Geometry"
			"LightMode" = "ForwardBase"
		}

		Pass {
			Cull Off
			ZTest LEqual

			CGPROGRAM
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			#include "AutoLight.cginc"

			sampler2D _MainTex;
			float4 _MainTex_ST;
			float4 _ShadowColor;
			sampler2D _FallOffSampler;
			float _FALLOFF_POWER;
			sampler2D _SpecularReflectionSampler;
			float _SpecularPower;

			struct v2f {
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
				float3 viewDir : TEXCOORD1;
				float3 normal : TEXCOORD2;
				float3 tangent : TEXCOORD3;
				float3 binormal : TEXCOORD4;
				float3 lightDir : TEXCOORD5;
				LIGHTING_COORDS( 6, 7 )
			};

			v2f vert(appdata_tan v) {
				v2f o;

				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv.xy = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
				//_Object2World是针对四维向量的,如果要用法线直接乘,需要在后面补一个0,我们就直接用内置函数算了
				//o.normal = mul(_Object2World, v.normal).xyz; 
				o.normal = UnityObjectToWorldNormal(v.normal);
				
				half4 worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.viewDir.xyz = normalize(_WorldSpaceCameraPos.xyz - worldPos.xyz).xyz;
				//得到世界空间视线方向

				o.tangent = v.tangent.xyz;
				o.binormal = cross(v.normal, v.tangent.xyz) * v.tangent.w;
				//副切线在切线空间里

				o.lightDir = WorldSpaceLightDir(v.vertex);

				TRANSFER_VERTEX_TO_FRAGMENT( o );

				return o;
			}

			float4 frag(v2f i) : SV_Target {
				//Diffuse
				float4 diffuseSamplerColor = tex2D(_MainTex, i.uv.xy); //取材质色

				float3 normalVec = i.normal;
				float normalDotEye = dot(i.normal, i.viewDir.xyz); //视线和法线的点乘
				float fallOffU = clamp(1.0 - abs(normalDotEye), 0.02, 0.98); //点乘取反

				float4 fallOffSamplerColor = _FALLOFF_POWER * tex2D(_FallOffSampler, float2(fallOffU, 0.25f)); //取颜色衰减
				float3 shadowColor = diffuseSamplerColor.rgb * diffuseSamplerColor.rgb; //将材质色平方,得到一个深色
				float3 combinedColor = lerp(diffuseSamplerColor.rgb, shadowColor, fallOffSamplerColor.r); //根据颜色衰减,取材质色到深色的中间色
				combinedColor *= (1.0 + fallOffSamplerColor.rgb * fallOffSamplerColor.a); //一定程度补偿深色变亮

				//Specular
				float4 reflectionMaskColor = tex2D(_SpecularReflectionSampler, i.uv.xy); //我也不太清楚这张高光图怎么来的
				float specularDot = normalDotEye; //在真实系渲染里,这个值应该是Normal dot H向量(光照 + 视线的法向量,一般用H表示)
												  //这里把光照当做视线,所以H就是视线向量,Normal dot 视线向量就是上面求过的normalDotEye
				float4 lighting = lit(normalDotEye, specularDot, _SpecularPower); //CG的内置函数
				float3 specularColor = saturate(lighting.z) * reflectionMaskColor.rgb * diffuseSamplerColor.rgb;
				combinedColor += specularColor; //add混合,只要是做加法,那肯定是变亮

				//EnviromentReflection
				//因为我们不需要环境反射,所以就不写这个了

				//Cast Shadow,接收阴影
				shadowColor = _ShadowColor.rgb * combinedColor.rgb;
				float attenuation = saturate(2.0 * LIGHT_ATTENUATION(i) - 1.0); //光衰减低的地方不处理,光衰减强的地方加强处理
				combinedColor = lerp(shadowColor, combinedColor, attenuation);

				return float4(combinedColor.rgb, 1.0);
			}

			ENDCG
		}


	}

	FallBack "Transparent/Cutout/Diffuse"
} 

作者这里有个小trick,对光衰减进行了映射,光衰减弱的地方,不进行颜色混合。光衰减强的地方(就是光源照不到的地方),加强阴影处理。下面是对比图,上图进行了映射,下图没有,注意看腋下

最后是边缘高光

				//Rimlight 边缘高光
				float rimlightDot = saturate(0.5 * (dot(normalVec ,i.lightDir) + 1.0)); //把-1~1映射到0~1
				fallOffU = saturate(rimlightDot * fallOffU); //先进行第一次映射,排除掉大部分非边缘部位
				fallOffU = tex2D(_RimLightSampler, float2(fallOffU, 0.25f)).r; //第二次映射,再排除掉绝大部分非边缘部位
				float3 lightColor = diffuseSamplerColor.rgb;
				combinedColor += fallOffU * lightColor; //Add加亮

Add保证颜色变明亮

这里我不是太想使用菲尼尔的物理计算,那样细节太多,干脆用两张采样图映射两次,一样可以排除掉非边缘部位

第二张映射图


两次映射后的效果

只进行一次映射的效果


衣服这块的shader我们就打完了,下面是看skin的shader,其实就是cloth的shader删掉了Specular和环境反射。不过我发现好像改了代码之后cast shadow不能正常起效,建了个方块挡住平行光也看不到变化,不太知道为什么,如果有发现错误的大佬请留言给我,谢谢……

原diffuse太黄了,用FallOffPower稍微改白了一点,好看多了

眼睛也是直接用的skin.shader

skin代码:

Shader "UnityChan-Self/Skin" {
	Properties {
		_MainTex ("Diffuse", 2D)= "white" {} //albedo材质,定义底色
		_ShadowColor ("Shadow Color", Color) = (0.8, 0.8, 1, 1)
		_FallOffSampler ("FallOffSampler", 2D) = "white" {} 
		_FALLOFF_POWER ("Falloff Power", Float) = 1.0
		_RimLightSampler ("RimLight Control", 2D) = "white" {}
	}

	SubShader {
		Blend SrcAlpha OneMinusSrcAlpha, One One 
		Tags {
			"RenderType" = "Overlay"	//这个是他的工程有调用替换着色器,我们是删掉也无所谓
			"IgnoreProjector"="True"
			"Queue" = "Geometry"
			"LightMode" = "ForwardBase"
		}

		Pass {
			Cull Back
			ZTest LEqual

			CGPROGRAM
			#pragma multi_compile_fwdbase
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			#include "AutoLight.cginc"

			sampler2D _MainTex;
			float4 _MainTex_ST;
			float4 _ShadowColor;
			sampler2D _FallOffSampler;
			float _FALLOFF_POWER;
			sampler2D _RimLightSampler;

			struct v2f {
				float4 pos : SV_POSITION;
				LIGHTING_COORDS( 0, 1 )
				float2 uv : TEXCOORD2;
				float3 viewDir : TEXCOORD3;
				float3 normal : TEXCOORD4;
				float3 lightDir : TEXCOORD5;
			};

			v2f vert(appdata_tan v) {
				v2f o;

				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
				//_Object2World是针对四维向量的,如果要用法线直接乘,需要在后面补一个0,我们就直接用内置函数算了
				//o.normal = mul(_Object2World, v.normal).xyz; 
				o.normal = UnityObjectToWorldNormal(v.normal);
				
				half4 worldPos = mul(unity_ObjectToWorld, v.vertex);
				o.viewDir.xyz = normalize(_WorldSpaceCameraPos.xyz - worldPos.xyz).xyz;
				//得到世界空间视线方向

				o.lightDir = WorldSpaceLightDir(v.vertex);

				TRANSFER_VERTEX_TO_FRAGMENT( o );

				return o;
			}

			float4 frag(v2f i) : Color {
				//Diffuse
				float4 diffuseSamplerColor = tex2D(_MainTex, i.uv.xy); //取材质色

				float3 normalVec = i.normal;
				float normalDotEye = dot(i.normal, i.viewDir.xyz); //视线和法线的点乘
				float fallOffU = clamp(1.0 - abs(normalDotEye), 0.02, 0.98); //点乘取反

				float4 fallOffSamplerColor = _FALLOFF_POWER * tex2D(_FallOffSampler, float2(fallOffU, 0.25f)); //取颜色衰减
				float3 shadowColor = diffuseSamplerColor.rgb * diffuseSamplerColor.rgb; //将材质色平方,得到一个深色
				float3 combinedColor = lerp(diffuseSamplerColor.rgb, shadowColor, fallOffSamplerColor.r); //根据颜色衰减,取材质色到深色的中间色

				//EnviromentReflection
				//因为我们不需要环境反射,所以就不写这个了

				//Cast Shadow,接收阴影
				shadowColor = _ShadowColor.rgb * combinedColor.rgb;
				float attenuation = saturate(2.0 * LIGHT_ATTENUATION(i) - 1.0); //光衰减低的地方不处理,光衰减强的地方加强处理
				combinedColor = lerp(shadowColor, combinedColor, attenuation);

				//Rimlight 边缘高光
				float rimlightDot = saturate(0.5 * (dot(normalVec ,i.lightDir) + 1.0)); //把-1~1映射到0~1
				fallOffU = saturate(rimlightDot * fallOffU); //先进行第一次映射,排除掉大部分非边缘部位
				fallOffU = tex2D(_RimLightSampler, float2(fallOffU, 0.25f)).r; //第二次映射,再排除掉绝大部分非边缘部位
				float3 lightColor = diffuseSamplerColor.rgb *0.15; //数值比cloth的低,因为不想让皮肤有白色的边缘高光
				combinedColor += fallOffU * lightColor; //Add加亮

				//return float4(LIGHT_ATTENUATION(i), LIGHT_ATTENUATION(i), LIGHT_ATTENUATION(i), 1.0);
				return float4(combinedColor.rgb, 1.0);
			}

			ENDCG
		}


	}

	FallBack "Transparent/Cutout/Diffuse"
}

最后的最后是描边效果,熟悉日漫的都知道,日漫都喜欢在线稿上描边。不过自动算的描边效果老实说很难达到近距离看所需要的要求,这时候就需要美工神出来施展神通了

效果图

描边的PASS放在渲染的Pass后面,用深度检测排除掉无需渲染的部分可以减少运算量。描边的颜色是根据diffuse加深得到的

Pass {
			Cull Front
			ZTest Less

			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			
			#define INV_EDGE_THICKNESS_DIVISOR 0.00285
			#define SATURATION_FACTOR 0.6
			#define BRIGHTNESS_FACTOR 0.8

			sampler2D _MainTex;
			float4 _MainTex_ST;

			float _EdgeThickness;

			struct v2f {
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
			};

			v2f vert(appdata_base v) {
				v2f o;
				o.uv = TRANSFORM_TEX(v.texcoord.xy, _MainTex);

				half4 projSpacePos = UnityObjectToClipPos(v.vertex); //裁剪空间坐标
				half4 projectSpaceNormal = normalize(UnityObjectToClipPos(half4(v.normal, 0))); //裁剪空间的法线
				half4 scaleNormal = _EdgeThickness * INV_EDGE_THICKNESS_DIVISOR * projectSpaceNormal;  //法线向外扩张
				scaleNormal.z += 0.00001; //防止法线为0
				o.pos = projSpacePos + scaleNormal; //坐标位移

				return o;
			}

			float4 frag(v2f i) : SV_Target {
				float4 diffuseColor = tex2D(_MainTex, i.uv);
				float maxChan = max(max(diffuseColor.r, diffuseColor.g), diffuseColor.b); //rgb通道里存储最大的那个
				float4 newMapColor = diffuseColor;

				maxChan -= ( 1.0 / 255.0 );
				float3 lerpVals = saturate( ( newMapColor.rgb - float3( maxChan, maxChan, maxChan ) ) * 255.0 ); //得到rgb最大的通道的1,其余为0
				newMapColor.rgb = lerp( SATURATION_FACTOR * newMapColor.rgb, newMapColor.rgb, lerpVals ); //rgb最大通道的保留,其余降色

				return float4( BRIGHTNESS_FACTOR * newMapColor.rgb * diffuseColor.rgb, diffuseColor.a );
			}

			ENDCG
		}



EX补充

原作者没有给头发加头发独有的高光(什么叫头发独有的高光可以参考本文:http://www.graphics.stanford.edu/papers/hair/hair-sg03final.pdf),我觉得加了会更好看,所以试着加一下

上面文章的渲染效果太物理了,也太复杂了,不可能拿来实时渲染的,实时渲染这本书给了我们一个近似模型

为当前点到光源方向,为当前点到视点方向,为头发的切线方向(从发根到末端),为头发的光泽度,则最终的高光强度系数可这样计算:

因为我们的模型不可能像真实头发那样细腻,而是一整块一整块的,所以需要一张燥波图来模拟头发副切线的随机偏移。并且实时渲染还指出,物理条件下同时存在主高光和副高光影响视觉效果,所以燥波图要两张。为了模拟主高光和副高光,对切线分别朝法线方向做两次不同程度的偏移,然后计算后相加


因为我很懒,不想找第二张燥波图,所以只计算了写了主高光的情况

shader

				//头发的反光,采用真实渲染方式
				float3 mainTangent = i.binormal + normalVec * _OffSetRange * tex2D(_HairSpecularOffMap, i.uv.xy).r; //计算主高光副切线的偏移
				float3 halfDir = normalize(i.viewDir + i.lightDir); //H向量
				float dotTH = dot(mainTangent, halfDir); 
				float sqrTH =max(0.0001, sqrt(1 - pow(dotTH, 2)));
				float atten =smoothstep(-1, 0, dotTH);   
				float primaryHairSpecularPower = atten * pow(sqrTH, _HairSpecularSmooth);  
				//return float4(primaryHairSpecularPower, primaryHairSpecularPower,primaryHairSpecularPower,1.0);
				combinedColor += primaryHairSpecularPower * _HairSpecularRange * combinedColor; //直接取底色作为高光颜色,也可以自己另外弄

高光黑白效果图,看起来和现实情况已经比较像了


混合底色后还OK


猜你喜欢

转载自blog.csdn.net/keven2148/article/details/80071795