在URP中,SurfaceShader已经不再被支持了,学URP和HLSL去吧,别碰SurfaceShader了。
前言
没错,又是老生常谈的NRP(非真实)渲染,或者说卡通渲染。最近事情不多,研究了一下Shader方面感兴趣的东西,首先试了一下用RenderTexture实现的实时MatCap,有点意思但找不到什么应用场景,暂时丢到一边了。然后不知道为什么又回到了NPR上面,于是就照着现在公司项目中的渲染效果为参考来编写了。最后的结果感觉完成度还可以,所以拿出来吹逼一下。
Outline 描边
搞NPR的要点之一无疑就是描边,关于这个,有件事就想提一下。我写Shader都是用SurfaceShader编写。之前做描边碰到的最大的问题就是怎么实现描边,因为那时候看到不止一篇文章说SurfaceShader没有Pass,就非常僵硬。但是,这次我在搜索的时候居然发现原来SurfaceShader是可以加Pass的!所以描边问题自然就引刃而解了,感谢这篇文章。
做法是传统的Inverted-Hull,代码是从Toony Colors Pro 2插件中抄来的,翻译了一下变成SurfaceShader可以用的代码。支持多种途径(通常、顶点颜色、切线、UV)来控制描边,支持固定宽度,支持通过一些参数微调,效果蛮玄学的,不过总比不能调要好。
不要用Cutout
有时制作头发或者衣服上有镂空之类,会采用Cutout的做法,在Shader里使用clip函数对像素进行剔除。但是想要使用Inverted-Hull来做描边的话,这种做法就不行了,因为描边是跟着mesh走的,剔除掉表面的像素并不会改变mesh的形状,描边就会出现问题。而且面片对这种描边方式本身就不友好,所以不要再用面片做头发了,请做出体积!
自定义光照:二刺螈
二刺螈不需要渐变!一般的Lambert模型的光照计算公式为dot(normal, lightDir) * atten
,最简单的做法——对其round一下就可以使明暗分离为两层了。
某些情况下,仅仅两层的明暗关系可能不够用,所以使Shader还支持了Ramp贴图来对光照进行映射。可以自己制作不同的Ramp贴图来实现想要的效果。适当采用一些渐变也有着反锯齿的效果(下图中间)。
其实这也是非常基础的操作,在Unity官方的Surface Shader Custom Lighting Example里就有示例。
五彩斑斓的黑
暗部如果只是纯黑色就显得很闷了,现在日系插画都有着很漂亮的暗部颜色,所以增加了对暗部进行着色的功能,混色算法采用了PS图层混合模式的“滤色”模式。
Cel贴图
研究公司项目里的角色渲染时,发现存在一张被广泛的使用的被称为Cel的贴图,用来控制阴影形状,有点法线贴图的意思。尝试反推了该贴图的用法,使用后可以在头发和衣服褶皱等地方看到明显的效果。
边缘光
这个很常见也很简单我就不多说了,总之在NPR中也是蛮必要的一种效果。
Stylized Highlight 风格化高光
一开始用传统Blinn-Phong模型的高光算法,效果相当恶心,所幸找到了一个好用的轮子——风格化的高光。代码是从这里来的,我翻译了一下,然后添加了一个SpecMask贴图的功能——对于不需要显示高光的区域涂黑即可。
友情提示:此效果不适合面数很低的模型。
关于反锯齿
由于有外描边这种细线的存在,不进行反锯齿就很容易满屏幕狗牙,分辨率越低越明显,所以极力推荐采取一定的反锯齿措施。不管是MSAA还是后期处理的TAA或FXAA(在官方的PostProcessing包中就有),都会让画面观感明显变好,顺便再配合一些此类渲染必备的Bloom效果,就可以获得比较满意的画面了。
关于打光
和一般的实时光照打光方式相同,推荐一个Directional Light即可。此外也会受环境光(Environment Lighting)影响, 可以在Lighting页面里调整。
完整代码
特性大致就是以上这些了,下面是完整的Shader代码。
// ----------一些参考----------
// http://www.ggxrd.com/Motomura_Junya_GuiltyGearXrd.pdf
// ----------------------------
Shader "Gypsum/Cel-Shading" {
Properties {
[Header(Culling)]
[Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull Mode", Float) = 2
[Space(5)]
[Header(Base Color)]
_Color ("Tint", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
[Header(Cel Shading Parameters)]
_ShadowColor ("Shadow Color", Color) = (0,0,0,1)
[Toggle(_ENABLE_RAMP)] _EnableRamp ("Enable Ramp", float) = 0.0
_RampTex ("Ramp Map", 2D) = "white" {}
_CelTex ("Cel Map", 2D) = "white" {}
_CelOffset ("Cel Offset", Range(-1,1)) = 0
[Space(5)]
[Header(Rim Light)]
[HDR] _RimColor ("Rim Color", Color) = (1,1,1,1)
_RimPower ("Rim Power", Range(1,64)) = 8
[Space(5)]
[Header(Outline)]
[KeywordEnum(REGULAR,VERTEXCOLOR,TANGENT,UV2)] _OutlineNormalMode ("Normal Mode", float) = 0.0
[Toggle(_OUTLINECONSTWIDTH)] _OutlineConstWidth ("Constant Width", float) = 0.0
_OutlineColor ("Color", Color) = (0, 0, 0, 1)
_OutlineWidth ("Width", Range(0,5)) = 1.0
[Toggle(_OUTLINEZSMOOTH)] _OutlineZSmooth ("Enable Z Correction", float) = 0.0
_ZSmooth ("Z Correction", Range(-3.0,3.0)) = -0.5
_Offset1 ("Z Offset", Float) = 0
// _Offset2 ("Z Offset 2", Float) = 0 //似乎没什么作用所以没有启用
[Space(5)]
[Header(Specular)]
[Toggle(_ENABLE_SPECULAR)] _EnableSpecular ("Enable", float) = 0.0
[HDR] _SpecularColor ("Color", Color) = (1, 1, 1, 1)
_SpecularMask ("Mask", 2D) = "white" {}
_SpecularPower ("Shininess", Range(1, 100)) = 48
_SpecularSegment ("Segment", Range(0, 1)) = 0.9
}
Subshader {
Tags { "RenderType"="Opaque"}
CGPROGRAM
#pragma surface surf Cel addshadow
#pragma shader_feature _ENABLE_SPECULAR
#pragma shader_feature _ENABLE_RAMP
sampler1D _RampTex;
sampler2D _CelTex;
sampler2D _MainTex;
sampler2D _SpecularMask;
fixed _CelOffset;
fixed4 _ShadowColor;
fixed4 _Color;
fixed4 _RimColor;
half _RimPower;
half4 _SpecularColor;
half _SpecularPower;
fixed _SpecularSegment;
// ----------一些颜色混合函数----------
fixed Greyscale(fixed3 input)
{
return (input.r + input.g + input.b) / 3;
}
fixed3 Blend_Multiply(fixed3 color0, fixed3 color1)
{
return color0 * color1;
}
fixed3 Blend_Overlay(fixed3 color0, fixed3 color1)
{
if(Greyscale(color0) <= 0.5)
{
return 2 * color0 * color1;
}
else
{
return 1 - 2 * ((1 - color0) * (1 - color1));
}
}
fixed3 Blend_Screen(fixed3 color0, fixed3 color1)
{
return 1 - (1 - color0) * (1 - color1);
}
// ------------------------------------
struct Input {
float2 uv_MainTex;
float3 viewDir;
// fixed3 worldNormal;
// float3 worldPos;
};
// 自定义一个SurfaceOutput
struct SurfaceOutputCel
{
fixed3 Albedo;
fixed3 Emission;
float3 Normal;
fixed Alpha;
half2 UV; //在Lighting函数中贴图就需要传UV到Output中
// fixed3 WorldNormal;
// float3 WorldPos;
};
void surf(Input IN, inout SurfaceOutputCel o)
{
// Input to Output
o.UV = IN.uv_MainTex;
// o.WorldNormal = IN.worldNormal;
// o.WorldPos = IN.worldPos;
// Rim light
fixed rim = dot(o.Normal, IN.viewDir);
rim = (saturate(pow(1 - rim, _RimPower)));
fixed3 finalRim = rim * _RimColor.rgb * _RimColor.a;
o.Emission = finalRim;
// Base Color
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
half4 LightingCel(SurfaceOutputCel s, half3 lightDir, half3 viewDir, half atten)
{
// ----------Stylized Highlights----------
// https://github.com/candycat1992/NPR_Lab
// ---------------------------------------
#ifdef _ENABLE_SPECULAR
fixed3 worldNormal = normalize(s.Normal);
fixed3 worldHalfDir = normalize(viewDir + lightDir);
fixed spec = max(0, dot(worldNormal, worldHalfDir));
spec = pow(spec, _SpecularPower);
fixed w = fwidth(spec);
if (spec < _SpecularSegment + w) {
spec = lerp(0, _SpecularSegment, smoothstep(_SpecularSegment - w, _SpecularSegment + w, spec));
} else {
spec = _SpecularSegment;
}
half3 specular = spec * _SpecularColor.rgb * tex2D(_SpecularMask, s.UV);
#else
fixed3 specular = 0;
#endif
// ----------------------------------------
// ----------Cel-Shading Lighting----------
// ----------------------------------------
half NdotL = dot(s.Normal, lightDir);
half cel = lerp(fixed3(1,1,1), saturate(Greyscale(tex2D(_CelTex, s.UV) + _CelOffset)), dot(lightDir,s.Normal));
#ifdef _ENABLE_RAMP
cel = tex1D(_RampTex, cel);
half ramp = tex1D(_RampTex, saturate(atten * NdotL) * 0.5 + 0.5);
half3 shadow = lerp(fixed3(1,1,1), Blend_Screen(fixed3(1,1,1) * saturate(ramp * cel), _ShadowColor.rgb), _ShadowColor.a);
#else
half3 shadow = lerp(fixed3(1,1,1), Blend_Screen(fixed3(1,1,1) * saturate(round(NdotL * atten * cel)), _ShadowColor.rgb), _ShadowColor.a);
#endif
half4 c;
c.rgb = Blend_Screen(shadow * s.Albedo * _LightColor0, specular);
c.a = s.Alpha;
return c;
}
ENDCG
// ----------Outline Pass----------
// https://www.videopoetics.com/tutorials/pixel-perfect-outline-shaders-unity/#building-the-classic-outline-shader
// https://assetstore.unity.com/packages/vfx/shaders/toony-colors-pro-2-8105
// --------------------------------
Pass {
Cull Front
Offset [_Offset1], 0 //[_Offset2]
CGPROGRAM
#include "UnityCG.cginc"
#pragma multi_compile _OUTLINENORMALMODE_REGULAR _OUTLINENORMALMODE_VERTEXCOLOR _OUTLINENORMALMODE_TANGENT _OUTLINENORMALMODE_UV2
#pragma shader_feature _OUTLINECONSTWIDTH
#pragma shader_feature _OUTLINEZSMOOTH
#pragma vertex Vertex
#pragma fragment Fragment
half _ZSmooth;
half _OutlineWidth;
half4 _OutlineColor;
struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 pos : SV_POSITION;
};
v2f Vertex(a2v v)
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
//Correct Z artefacts
#ifdef _OUTLINEZSMOOTH
float4 pos = float4(UnityObjectToViewPos(v.vertex), 1.0);
#ifdef _OUTLINENORMALMODE_VERTEXCOLOR
//Vertex Color for Normals
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, (v.color.xyz*2) - 1);
#elif _OUTLINENORMALMODE_TANGENT
//Tangent for Normals
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.tangent.xyz);
#elif _OUTLINENORMALMODE_UV2
//UV2 for Normals
float3 normal;
//unpack uv2
v.uv2.x = v.uv2.x * 255.0/16.0;
normal.x = floor(v.uv2.x) / 15.0;
normal.y = frac(v.uv2.x) * 16.0 / 15.0;
//get z
normal.z = v.uv2.y;
//transform
normal = mul( (float3x3)UNITY_MATRIX_IT_MV, normal*2-1);
#else
float3 normal = mul( (float3x3)UNITY_MATRIX_IT_MV, v.normal);
#endif
normal.z = -_ZSmooth;
#ifdef _OUTLINECONSTWIDTH
//Camera-independent outline size
float dist = distance(_WorldSpaceCameraPos, mul(unity_ObjectToWorld, v.vertex));
pos = pos + float4(normalize(normal),0) * _OutlineWidth * 0.01 * dist;
#else
pos = pos + float4(normalize(normal),0) * _OutlineWidth * 0.01;
#endif
#else
#ifdef _OUTLINENORMALMODE_VERTEXCOLOR
//Vertex Color for Normals
float3 normal = (v.color.xyz*2) - 1;
#elif _OUTLINENORMALMODE_TANGENT
//Tangent for Normals
float3 normal = v.tangent.xyz;
#elif _OUTLINENORMALMODE_UV2
//UV2 for Normals
float3 n;
//unpack uv2
v.uv2.x = v.uv2.x * 255.0/16.0;
n.x = floor(v.uv2.x) / 15.0;
n.y = frac(v.uv2.x) * 16.0 / 15.0;
//get z
n.z = v.uv2.y;
//transform
n = n*2 - 1;
float3 normal = n;
#else
float3 normal = v.normal;
#endif
//Camera-independent outline size
#ifdef _OUTLINECONSTWIDTH
float dist = distance(_WorldSpaceCameraPos, mul(unity_ObjectToWorld, v.vertex));
float4 pos = float4(UnityObjectToViewPos(v.vertex + float4(normal, 0) * _OutlineWidth * 0.01 * dist), 1.0);
#else
float4 pos = float4(UnityObjectToViewPos(v.vertex + float4(normal, 0) * _OutlineWidth * 0.01), 1.0);
#endif
#endif
o.pos = mul(UNITY_MATRIX_P, pos);
return o;
}
float4 Fragment (v2f IN) : COLOR
{
return _OutlineColor;
}
ENDCG
}
}
}
结语
之前第一次看《罪恶装备Xrd》艺术风格讲解的时候,有种惊为天人的感觉,就一直想着自己什么时候也可以试着搞一下这类Shader。其实NPR都是一些很老的技术,好几年前就可以做到了,只是它的难点从来就不是技术。
PPT里有几个点我认为讲得非常好:
不只是一个Shader就完事,而是要构建整个工作流。(Not just a shader, but a whole workflow)
他们的人物动画每一帧都是手K,而且不做补间,是为了追求“有限动画”的感觉。并且动画的每一帧都会对光照做针对性调整,还充斥着大量的形变缩放,以营造日式动画类似“金田系”作画的夸张透视效果。最后游戏能呈现出这样几乎没有破绽的2D效果,巨大的美术工作量的功不可没。试图用一个Shader就想让自己的游戏达到完美的风格化渲染效果,无疑是天真的。让美术决定效果,而不是数学公式。(Let the artist decide, not the math)
确实有时候就会碰到这种情况——这个公式看起来更正确一点,但是效果很微妙;那种算法看起来很莫名其妙,但是效果很棒。所以该用哪种?可能大部分时候我们只需要表象正确就可以了,毕竟做游戏就少不了Trick,没必要一味的追求“正确”吧。
以上是一些个人的小小感想。希望本文对你有用,再见。