Skip to content
BigBro222's Blog
LinkedInGitHub

Toon Shaders

Unity development, Shader Effects, Graphics2 min read

Toon shading (often called cel shading) is a rendering style designed to make 3D surfaces emulate 2D, flat surfaces. This is a kind of NPR(Non-Photorealistic Rendering).The core concept of this shading technique is to have sharp diffuse edge and sharp specular reflections and also an Outline.

1. How to write a simple toon shader with unlit shader

1.1 Diffuse

1//Diffuse light steps
2 float difLight=dot(worldLightDir,i.worldNormal)*0.5+0.5;
3 difLight = smoothstep(0,1,difLight);
4 float toon=floor(difLight*_Steps)/_Steps;
5 difLight=lerp(difLight,toon,_ToonEffect);
6 fixed3 diffuse=_LightColor0.rgb*albedo*_DiffuseColor.rgb*difLight;

1.2 Specular

The specular calculation of Blinn-Phong light model is to to use the dot product of vertex normal and the middle angle of lightdir and viewdir (halfdir)

1//halfDir is the middle angle of lightdir and viewdir
2//_Gloss is a factor to controll specular strength
3float spec=pow(max(0,dot(normal,halfDir)),_Gloss)

But for toon shading, we skip the Gloss power and compare the dot product with a threshold using the step function. If the dot product is greater than the threshold the specular is 1, otherwise is 0

1float spec=dot(normal,halfDir);
2spec = step(threshold,spec);

1.3 Rim

Rim is related to the angle of view dir and vertex normal dir.

Rim lighting is the addition of illumination to the edges of an object to simulate reflected light or backlighting. It is especially useful for toon shaders to help the object's silhouette stand out among the flat shaded surfaces.

The "rim" of an object will be defined as surfaces that are facing away from the camera. We will therefore calculate the rim by taking the dot product of the normal and the view direction, and inverting it.

1float rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rimDot);
2float4 rim = rimIntensity * _RimColor;

1.4 Outline

To render an outline, here we are using a simple method: to add an additional pass and add an outline offset and then draw the normal object on the top of this pass

1//1.object normal extrusion
2v.vertex.xyz+=_Outline*v.normal;
3o.vertex = UnityObjectToClipPos(v.vertex);
4
5//2.view space normal extrusion
6float4 pos = mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, v.vertex));
7float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV,v.normal));
8pos = pos + float4(normal,0) * _Outline;
9o.vertex = mul(UNITY_MATRIX_P, pos);
10
11//3.clip space normal extrusion
12o.vertex = UnityObjectToClipPos(v.vertex);
13float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal));
14float2 viewNormal = TransformViewToProjection(normal.xy);
15o.vertex.xy += viewNormal * _Outline;

Assets
Figure 1 Simple toon shader

here is a full version of the toon shader based on vertex and fragment shader

1Shader "Toon/ToonShader"
2{
3 Properties
4 {
5 _MainTex ("Texture", 2D) = "white" {}
6 _DiffuseColor("Diffuse",Color)=(1,1,1,1)
7 _Outline("Outline Width",Range(0,1))=1
8 _OutlineColor("Outline Color",Color)=(0,0,0,1)
9 _Steps("Steps",Range(1,30)) = 1
10 _ToonEffect("ToonEffect", Range(0,1)) = 0.5
11 _Specular("Specular Color",Color)=(1,1,1,1)
12 _SpecularScale("Specular Scale",Range(0.0001,3))=1
13 _RimColor("Rim Light Color",Color)=(1,1,1,1)
14 _RimPower("Rim Strength", Range(0.00000001,3))=1
15 _XRayColor("Oculusion Color",Color)=(1,1,1,1)
16 _XRayPower("XRay Power",Range(0.0001,3))=1
17 }
18 SubShader
19 {
20 Tags
21 {
22 "Queue"= "Geometry+1000" "RenderType"="Opaque"
23 }
24 LOD 100
25 Pass
26 {
27 Name "Outline"
28 Cull Front
29
30 CGPROGRAM
31 #pragma vertex vert
32 #pragma fragment frag
33
34 #include "UnityCG.cginc"
35 #include "Lighting.cginc"
36
37 struct appdata
38 {
39 float4 vertex : POSITION;
40 float2 uv : TEXCOORD0;
41 };
42
43 struct v2f
44 {
45 float4 vertex : SV_POSITION;
46 };
47
48 float _Outline;
49 fixed4 _OutlineColor;
50 float _Steps;
51 float _ToolEffect;
52
53 v2f vert(appdata_base v)
54 {
55 v2f o;
56 //物体法线外扩
57 //v.vertex.xyz+=_Outline*v.normal;
58 //o.vertex = UnityObjectToClipPos(v.vertex);
59
60 //视角空间法线外拓
61 //float4 pos = mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, v.vertex));
62 //float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV,v.normal));
63 //pos = pos + float4(normal,0) * _Outline;
64 //o.vertex = mul(UNITY_MATRIX_P, pos);
65
66 //裁剪空间法线外拓
67 o.vertex = UnityObjectToClipPos(v.vertex);
68 float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal));
69 float2 viewNormal = TransformViewToProjection(normal.xy);
70 o.vertex.xy += viewNormal * _Outline;
71 return o;
72 }
73
74 fixed4 frag(v2f i) : SV_Target
75 {
76 return _OutlineColor;
77 }
78 ENDCG
79 }
80
81 Pass
82 {
83 CGPROGRAM
84 #pragma vertex vert
85 #pragma fragment frag
86
87 #include "UnityCG.cginc"
88 #include "Lighting.cginc"
89
90 struct v2f
91 {
92 float4 pos : SV_POSITION;
93 float2 uv : TEXCOORD0;
94 fixed3 worldNormal:TEXCOORD1;
95 fixed3 worldPos:TEXCOORD2;
96 };
97
98 sampler2D _MainTex;
99 float4 _MainTex_ST;
100 float4 _DiffuseColor;
101 float _Steps;
102 float _ToonEffect;
103 fixed4 _RimColor;
104 float _RimPower;
105 float _SpecularScale;
106 fixed4 _Specular;
107
108 v2f vert(appdata_base v)
109 {
110 v2f o;
111 o.pos = UnityObjectToClipPos(v.vertex);
112 o.worldNormal = UnityObjectToWorldNormal(v.normal);
113 o.worldPos = mul(unity_ObjectToWorld, o.pos);
114 o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
115 return o;
116 }
117
118 fixed4 frag(v2f i) : SV_Target
119 {
120 // sample the texture
121 fixed4 albedo = tex2D(_MainTex, i.uv);
122 fixed3 worldLightDir = UnityWorldSpaceLightDir(i.worldPos);
123
124 //view Direction
125 fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
126
127 //Diffuse light steps
128 float difLight = dot(worldLightDir, i.worldNormal) * 0.5 + 0.5;
129 difLight = smoothstep(0, 1, difLight);
130 float toon = floor(difLight * _Steps) / _Steps;
131 difLight = lerp(difLight, toon, _ToonEffect);
132 fixed3 diffuse = _LightColor0.rgb * _DiffuseColor.rgb * difLight;
133
134 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
135
136 fixed3 halfDir = normalize(worldLightDir + viewDir);
137 float spec = dot(i.worldNormal, halfDir);
138 fixed w = fwidth(spec) * 2.0;
139 fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(
140 0.0001, _SpecularScale);
141
142 /*float rim = 1 - dot(i.worldNormal, viewDir);
143 fixed3 rimColor = _RimColor * pow(rim, 1 / _RimPower);*/
144 float rimdot = 1 - dot(i.worldNormal, viewDir);
145 float rimIntensity = smoothstep(_RimPower - 0.01, _RimPower + 0.01, rimdot);
146 float4 rim = rimIntensity * _RimColor;
147 return float4(ambient + diffuse + specular + rim, 1);
148 }
149 ENDCG
150 }
151 Pass
152 {
153 Tags
154 {
155 "LightMode"="ShadowCaster"
156 }
157
158 CGPROGRAM
159 #pragma vertex vert
160 #pragma fragment frag
161 #pragma multi_compile_shadowcaster
162 #include "UnityCG.cginc"
163
164 struct v2f
165 {
166 V2F_SHADOW_CASTER;
167 };
168
169 v2f vert(appdata_base v)
170 {
171 v2f o;
172 TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
173 return o;
174 }
175
176 float4 frag(v2f i) : SV_Target
177 {
178 SHADOW_CASTER_FRAGMENT(i)
179 }
180 ENDCG
181 }
182
183 }
184}

2. Toon effect in Surface shader

we can also use Surface shader to implement the toon effect. The main difference is that we need to customize the LightMode in Surface shader to achieve this. And the base calculation are similar to the unlit shader. Surface shader enable easier and fast implementation of shadow casting and multiple lights handle

1//our lighting function. Will be called once per light
2 float4 LightingStepped(SurfaceOutput s, float3 lightDir, half3 viewDir, float shadowAttenuation){
3 //how much does the normal point towards the light?
4 float towardsLight = dot(s.Normal, lightDir);
5 // make the lighting a hard cut
6 float towardsLightChange = fwidth(towardsLight);
7 float lightIntensity = smoothstep(0, towardsLightChange, towardsLight);
8
9 #ifdef USING_DIRECTIONAL_LIGHT
10 //for directional lights, get a hard vut in the middle of the shadow attenuation
11 float attenuationChange = fwidth(shadowAttenuation) * 0.5;
12 float shadow = smoothstep(0.5 - attenuationChange, 0.5 + attenuationChange, shadowAttenuation);
13 #else
14 //for other light types (point, spot), put the cutoff near black, so the falloff doesn't affect the range
15 float attenuationChange = fwidth(shadowAttenuation);
16 float shadow = smoothstep(0, attenuationChange, shadowAttenuation);
17 #endif
18 lightIntensity = lightIntensity * shadow;
19
20 //calculate shadow color and mix light and shadow based on the light. Then taint it based on the light color
21 float3 shadowColor = s.Albedo * _ShadowTint;
22 float4 color;
23 color.rgb = lerp(shadowColor, s.Albedo, lightIntensity) * _LightColor0.rgb;
24 color.a = s.Alpha;
25 return color;
26 }


Assets
Figure 2 Surface toon shader

Multiple lights and cast shadow

The full shader script is as follow

1Shader "Toon/ToonShaderSurfaceBigBro"
2{
3 Properties
4 {
5 _Color ("Color", Color) = (1,1,1,1)
6 _MainTex ("Albedo (RGB)", 2D) = "white" {}
7 _Outline("Outline Width",Range(0,1))=1
8 _OutlineColor("Outline Color",Color)=(0,0,0,1)
9 _Steps("Steps",Range(1,30)) = 1
10 _ToonEffect("ToonEffect", Range(0,1)) = 0.5
11 _DiffuseColor("Diffuse",Color)=(1,1,1,1)
12 _Specular("Specular Color",Color)=(1,1,1,1)
13 _SpecularScale("Specular Scale",Range(0.0001,3))=1
14 }
15 SubShader
16 {
17 Tags
18 {
19 "RenderType"="Opaque"
20 }
21 LOD 200
22 Pass
23 {
24 Name "Outline"
25 Cull Front
26
27 CGPROGRAM
28 #pragma vertex vert
29 #pragma fragment frag
30
31 #include "UnityCG.cginc"
32 #include "Lighting.cginc"
33
34 struct appdata
35 {
36 float4 vertex : POSITION;
37 float2 uv : TEXCOORD0;
38 };
39
40 struct v2f
41 {
42 float4 vertex : SV_POSITION;
43 };
44
45 float _Outline;
46 fixed4 _OutlineColor;
47
48 v2f vert(appdata_base v)
49 {
50 v2f o;
51 //裁剪空间法线外拓
52 o.vertex = UnityObjectToClipPos(v.vertex);
53 float3 normal = normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal));
54 float2 viewNormal = TransformViewToProjection(normal.xy);
55 o.vertex.xy += viewNormal * _Outline;
56 return o;
57 }
58
59 fixed4 frag(v2f i) : SV_Target
60 {
61 return _OutlineColor;
62 }
63 ENDCG
64 }
65
66 CGPROGRAM
67 #pragma surface surf Toon addshadow
68
69 float4 _DiffuseColor;
70 fixed4 _Color;
71 float _Steps;
72 float _ToonEffect;
73 float _SpecularScale;
74 fixed4 _Specular;
75
76 half4 LightingToon(SurfaceOutput s, half3 lightDir, half3 viewDir, half atten)
77 {
78 //Diffuse light steps
79 float difLight = dot(lightDir, s.Normal) * 0.5 + 0.5;
80 difLight = smoothstep(0, 1, difLight);
81 float toon = floor(difLight * _Steps) / _Steps;
82 difLight = lerp(difLight, toon, _ToonEffect);
83 fixed3 diff = _LightColor0.rgb * _DiffuseColor.rgb * difLight;
84
85 fixed3 halfDir = normalize(lightDir + viewDir);
86 float spec = dot(s.Normal, halfDir);
87 fixed w = fwidth(spec) * 2.0;
88 fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(
89 0.0001, _SpecularScale);
90
91 fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Color;
92
93 //custom shadow
94 #ifdef USING_DIRECTIONAL_LIGHT
95 float attenuationChange = fwidth(atten) * 0.5;
96 float shadow = smoothstep(0.5 - attenuationChange, 0.5 + attenuationChange, atten);
97 #else
98 float attenuationChange = fwidth(atten);
99 float shadow = smoothstep(0, attenuationChange, atten);
100 #endif
101 half4 c;
102 c.rgb = (ambient + diff + specular) * atten * s.Albedo;
103 c.a = s.Alpha;
104 return c;
105 }
106
107 struct Input
108 {
109 float2 uv_MainTex;
110 };
111
112 sampler2D _MainTex;
113
114 void surf(Input IN, inout SurfaceOutput o)
115 {
116 o.Albedo = tex2D(_MainTex, IN.uv_MainTex).rgb;
117 }
118 ENDCG
119 }
120 FallBack "Diffuse"
121}

3. Other effects in Toon shader

3.1 Light turbulance

To enable a light turbulance for a torch, the trick is to add as offset for the lightDir

1float3 shakeOffset = float3(0, 0, 0);
2shakeOffset.x = sin(_Time.z * 15);
3shakeOffset.y = sin(_Time.z * 13 + 5);
4shakeOffset.z = sin(_Time.z * 12 + 7);
5
6#ifdef POINT
7lightDir += shakeOffset * 0.1f;
8#endif


Assets
Figure 3 LightTurbulance

3.2 Hair specular

Kajiya-Kay Shading Model

3.3 With multiple lights

There is another way to add multiple lights. By default, the Unity light system only supports four light sources. But we can add lights manually to make sure that every light has been calculate throw for shadow casting.

First there should be a LitTag to mark which light we want to take count.

1public class MultiLitsTag:MonoBehaviour
2{
3 public float shakeStrength = 0.5f;
4 public int litSorting = 0;
5}

Then generate a multilights container, and send the following message to shader:

(float4)_LitPosList: xyz is the world position, z can be another light perticular factor

(float4)_LitColList:xyz is the light color, w is the light range

1public class MultiLitsContainer : MonoBehaviour
2{
3 [SerializeField] private Transform[] pointLits;
4
5 private Light[] _pointLightList;
6
7 private MultiLitsTag[] _lightTagList;
8 // Start is called before the first frame update
9 void Awake()
10 {
11 _pointLightList = new Light[pointLits.Length];
12 _lightTagList = new MultiLitsTag[pointLits.Length];
13 for (int i = 0; i < pointLits.Length; i++)
14 {
15 _pointLightList[i] = pointLits[i].GetComponent<Light>();
16 _lightTagList[i]=pointLits[i].GetComponent<MultiLitsTag>();
17 }
18 }
19
20 // Update is called once per frame
21 void Update()
22 {
23 Vector4[] litPosList = new Vector4[10];
24 Vector4[] litColList = new Vector4[10];
25
26 for (int i = 0; i < pointLits.Length; i++)
27 {
28 litPosList[i] = new Vector4(pointLits[i].position.x,pointLits[i].position.y,pointLits[i].position.z,_lightTagList[i].shakeStrength);
29 litColList[i] = new Vector4(_pointLightList[i].color.r,_pointLightList[i].color.g,_pointLightList[i].color.b,_pointLightList[i].range);
30 }
31 Shader.SetGlobalFloat("_LitCount",pointLits.Length);
32 Shader.SetGlobalVectorArray("_LitPosList",litPosList);
33 Shader.SetGlobalVectorArray("_LitColList",litColList);
34 }
35}

And the shader would be as following

1Shader "Toon/ToonMultipleLightShader"
2{
3 Properties
4 {
5 _Tint("Tint",Color)=(1,1,1,1)
6 _MainTex ("Texture", 2D) = "white" {}
7 _UnlitColor("Shadow Color",Color)=(0.5,0.5,0.5,1)
8 _MultiListFadeDistance("MultiList FadeDistance",Float)=20
9
10 _RimColor("Rim Color",Color)=(0.5,0.5,0.5,1)
11 _RimLightSampler ("RimLight Sampler", 2D) = "white" {}
12 _RimIntensity("Rim Intensity",Float)=10
13 _UnlitThreshold("Shadow Range",Range(0,1))=0.1
14 }
15 SubShader
16 {
17 Tags
18 {
19 "Queue"="Geometry" "RenderType"="Opaque"
20 }
21 LOD 100
22
23 Pass
24 {
25 Tags
26 {
27 "LightMode"="ForwardBase"
28 }
29 CGPROGRAM
30 #pragma vertex vert
31 #pragma fragment frag
32
33 #include "UnityCG.cginc"
34
35 #pragma multi_compile_fwdbase
36 #include "AutoLight.cginc"
37 #include "UnityLightingCommon.cginc"
38
39 struct appdata
40 {
41 float4 vertex : POSITION;
42 float3 normal : NORMAL;
43 float2 uv : TEXCOORD0;
44 };
45
46 struct v2f
47 {
48 float4 pos:SV_POSITION;
49 float4 posWorld:TEXCOORD0;
50 float3 normal:TEXCOORD1;
51 float2 uv : TEXCOORD2;
52 float3 camDir: TEXCOORD3;
53 float3 lightDir: TEXCOORD4;
54 LIGHTING_COORDS(5, 6)
55 };
56
57 sampler2D _MainTex;
58 float4 _MainTex_ST;
59 fixed4 _Tint;
60 fixed4 _UnlitColor;
61
62 float _UnlitThreshold;
63 float _MultiListFadeDistance;
64
65 fixed4 _RimColor;
66 sampler2D _RimLightSampler;
67 float _RimIntensity;
68
69 float _LitCount;
70 float4 _LitPosList[10];
71 float4 _LitColList[10];
72
73 v2f vert(appdata v)
74 {
75 v2f o;
76 o.pos = UnityObjectToClipPos(v.vertex);
77 o.posWorld = mul(unity_ObjectToWorld, v.vertex);
78 o.normal = UnityObjectToWorldNormal(v.normal);
79 o.camDir = normalize(_WorldSpaceCameraPos - o.posWorld);
80 o.lightDir = WorldSpaceLightDir(v.vertex);
81 o.uv = TRANSFORM_TEX(v.uv, _MainTex);
82 return o;
83 }
84
85 fixed4 frag(v2f i) : SV_Target
86 {
87 // sample the texture
88 fixed4 col = tex2D(_MainTex, i.uv) * _Tint;
89
90 //add fire shake effect
91 fixed4 pointLitCol = fixed4(0, 0, 0, 0);
92 fixed pointLit = 0;
93 float3 shakeOffset = float3(0, 0, 0);
94 shakeOffset.x = sin(_Time.z * 15);
95 shakeOffset.y = sin(_Time.z * 13+5);
96 shakeOffset.z = sin(_Time.z * 12+7);
97
98 for (int n = 0; n < _LitCount; n++)
99 {
100 float litDist = distance(_LitPosList[n].xyz, i.posWorld.xyz);
101 float viewDist = distance(_LitPosList[n].xyz, _WorldSpaceCameraPos);
102 float viewFade = 1 - saturate(viewDist / _MultiListFadeDistance);
103 if (litDist < _MultiListFadeDistance)
104 {
105 float3 litDir = _LitPosList[n].xyz - i.posWorld.xyz;
106 litDir += shakeOffset * 0.07 * _LitPosList[n].w;
107 litDir = normalize(litDir);
108 fixed newlitValue = max(0, dot(i.normal, litDir)) * (_LitPosList[n].w - litDist) * viewFade >
109 0.3;
110 fixed4 newlitCol = newlitValue * fixed4(_LitColList[n].xyz, 1);
111 pointLitCol = lerp(pointLitCol, newlitCol, newlitValue);
112 }
113 }
114
115 //light and shadow
116 float3 normalDirection = normalize(i.normal);
117 float attenuation = LIGHT_ATTENUATION(i);
118 float3 lightDirection = normalize(_WorldSpaceLightPos0).xyz;
119 fixed3 lightColor = _Tint.rgb * _UnlitColor.rgb * _LightColor0.rgb;
120 if (attenuation * max(0.0, dot(normalDirection, lightDirection)) >= _UnlitThreshold)
121 {
122 lightColor = _LightColor0.rgb * _Tint.rgb;
123 }
124
125 //Rimlight
126 float normalDotCam = dot(i.normal, i.camDir.xyz);
127 float falloffU = clamp(1.0 - abs(normalDotCam), 0.02, 0.98);
128
129 float rimlightDot = saturate(0.5 * (dot(i.normal, i.lightDir + float3(-1, 0, 0)) + 1.5));
130 falloffU = saturate(rimlightDot * falloffU);
131 falloffU = tex2D(_RimLightSampler, float2(falloffU, 0.25f)).r;
132 float3 rimCol = falloffU * col * _RimColor * _RimIntensity;
133
134 return float4(col.rgb * (lightColor.rgb + pointLitCol) + rimCol, 1.0);
135 }
136 ENDCG
137 }
138 }
139 Fallback "VertexLit"
140}


Assets
Figure 4 Multiple light toon shader

Reference

https://sorumi.xyz/posts/unity-toon-shader/

https://blog.csdn.net/QO_GQ/article/details/119616656

https://blog.csdn.net/csuyuanxing/article/details/123519895

https://www.cnblogs.com/littleperilla/p/15759993.html

https://roystan.net/articles/toon-shader/

https://www.ronja-tutorials.com/post/032-improved-toon/

http://walkingfat.com/《塞尔达-荒野之息》中角色受多个点光源影响做法/

http://tuyg.top/archives/876

https://docs.unity3d.com/Manual/SL-SurfaceShaderLightingExamples.html

© 2023 by BigBro222's Blog. All rights reserved.