A huge amount of effort has been made in 3D video games to produce “realistic” looking graphics. The idea is that you should feel like you’re playing a movie and the rendering should look like it was filmed with a camera. While there are some exceptions, other artistic styles — such as trying to look like a cartoon, a painting, a drawing, etc. — used to be largely ignored, or at least restricted to 2D games. Thankfully, this is changing. Especially so in the indy crowd, but also in big budget games like Team Fortress 2. This is great for graphics programmers to explore different rendering methods and lets a shader become part of an overall creative goal instead of a simulation of a camera.

For my game Space Rocks, I decided that I wanted it to look like the cover of a 60’s science fiction novel: illustrated, and somewhat cartoonish, but at the same time not cell shaded and outlined like a comic book or a Disney cartoon. As I mentioned, Valve accomplished something like this in TF2. What’s more they published a very good paper on their work.

This is a tutorial on how to implement this kind of shader in Unity3D. The complete shader source is attached at the end of the document. If you’ve never done any shader before, you might want to find try a few simpler tutorials first. However if you know a bit about shading languages, but are perhaps not familiar with Unity, this should give you a good introduction. The complete source is given at the end of this page.

# Paperwork: the bits that make up a Unity shader

We will be making a Unity3D “Surface Shader”. This isn’t so much a shader as a template from which Unity will create numerous vertex and fragment shaders for different rendering paths. To create a surface shader we specify a lighting model (in this case a custom one called LightingNPR) and a function that prepares the surface for lighting, surf. We will use a normal map (bump map) though it should be removed where it is not needed. Here’s our starting point:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
Shader "Jonatron/NPR (Example)" { Properties { _MainTex("Main Texture", 2D) = "white" {} // These properties must have the same names as in standard Unity _BumpMap("Bump Map", 2D) = "normal" {} // shaders in order for fallback shaders to correctly work. _Color("Color", Color) = (1.0, 1.0, 1.0, 1.0) } SubShader { Tags { "RenderType" = "Opaque" } // This allows Unity to intelligently substitute this shader when needed. LOD 200 CGPROGRAM #include "UnityCG.cginc" #pragma surface surf NPR // These match the shader properties uniform sampler2D _MainTex, _BumpMap; uniform float4 _Color; half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { // This is where we will put the lighting equation // It returns a colour } struct Input { // This contains the inputs to the surface function // Valid fields are listed at: // http://docs.unity3d.com/Documentation/Components/SL-SurfaceShaders.html float2 uv_MainTex; }; void surf(Input IN, inout SurfaceOutput o) { // This is where we prepare the surcace for lighting by propagating a SurfaceOutput structure half4 c = tex2D(_MainTex, IN.uv_MainTex); // Sample the texture o.Albedo = _Color.rgb * c; // Modulate by main colour o.Alpha = 1.0; // No alpha in this shader // Apply bump map o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_MainTex)); } ENDCG } FallBack "Diffuse" // Shader to use if the user's hardware cannot incorporate this one } |

The surf function is pretty standard, but we need to fill in LightingNPR .

# The basics: Lambert’s model

Even if you have never heard of “Lambertian” shading, you’ve undoubtedly seen this if ever touched shaders before. It is the basis of almost all shaders and is used to compute diffuse illumination, \(I_d\), by taking the cosine of the angle between the surface’s normal vector, \(\hat{\mathbf{n}}\) and a unit vector towards the light \(\hat{\mathbf{l}}\). This makes sense because when the surface directly faces the light this angle will be zero and it’s cosine 1; if the surface is at right angle to the light then the cosine will be 0. This cosine is trivial to compute as it is simply the dot product of the two vectors: \[I_d = \max\{\hat{\mathbf{n}}\cdot\hat{\mathbf{l}}, 0\}\] Let’s add this to our lighting function, and include the surface colour, light colour, and attenuation:

22 23 24 25 26 27 |
half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { // This is where we will put the lighting equation float lambert = saturate(dot(o.Normal, lightdir)); return half4(_LightColor0.rgb * o.Albedo.rgb * atten * lambert, 1.0); } |

This produces a smooth, realistic, and rather boring approximation of a matte surface, as illustrated by Suzanne the Blender Monkey:

# Closer to illustaion: Gooch shading

Lambert’s model is used to directly compute the brightness of a surface. However, Gooch observed that artists often use other cues in addition to just light and dark to show illumination and shading. Specifically, warm colours (red, yellows, and oranges) show illumination and cool colours (blues, teals, and purples) show shading. We can incorporate these principles very easily using a light ramp: a one dimensional colour gradient imported into our shader as a texture.

3 4 5 6 7 8 9 |
Properties { _MainTex("Main Texture", 2D) = "white" {} // These properties must have the same names as in standard Unity _BumpMap("Bump Map", 2D) = "normal" {} // shaders in order for fallback shaders to correctly work. _Ramp("Ramp", 2D) = "white" {} _Color("Color", Color) = (1.0, 0.8, 0.2, 1.0) } |

We still compute Lambert’s diffuse term, but instead of using it as a measure of lightness, we use it as a position in the light ramp:

15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
CGPROGRAM #include "UnityCG.cginc" #pragma surface surf NPR noambient // These match the shader properties uniform sampler2D _MainTex, _BumpMap, _Ramp; uniform float4 _Color; half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { float lambert = saturate(dot(o.Normal, lightdir)); half4 diffuse = half4(_LightColor0.rgb * atten * o.Albedo.rgb * lambert, 1.0); diffuse *= tex2D(_Ramp, float2(lambert, 0.0)); return diffuse; } |

When we combine this with a ramp from a dark, cool colour to a warm, bright colour, we get a result much closer to an artistic rendering:

This is an incredibly versatile tool for both illustrative and photorealistic shading. It can be used to produce some very unusual highlights:

And it can even be used to create a cell-shaded effect if the texture contains only two colours and uses no filtering:

# Controlling the transition: warped Lambert

The light ramp gives us excellent control, but for that final touch we want some finer control over how the shader traverses the ramp. To do this, we add a scale \(\alpha\), bias \(\beta\), and exponent \(\gamma\) to Lambert’s term: \[I_d = \left(\alpha(\max\{\hat{\mathbf{n}}\cdot\hat{\mathbf{l}},0\}) + \beta\right)^\gamma\]

3 4 5 6 7 8 9 10 11 12 |
Properties { _MainTex("Main Texture", 2D) = "white" {} // These properties must have the same names as in standard Unity _BumpMap("Bump Map", 2D) = "normal" {} // shaders in order for fallback shaders to correctly work. _Ramp("Ramp", 2D) = "white" {} _Color("Color", Color) = (1.0, 0.8, 0.2, 1.0) _DiffuseScale("Diffuse Scale", Float) = 1 _DiffuseBias("Diffuse Bias", Float) = 0 _DiffuseExponent("Diffuse Exponent", float) = 1 } |

27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
uniform sampler2D _MainTex, _BumpMap, _Ramp; uniform float4 _Color; uniform float _DiffuseScale, _DiffuseBias, _DiffuseExponent; half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { // Wrapped Lambertian diffuse term float lambert = saturate(dot(o.Normal, lightdir)); lambert = pow(lambert*_DiffuseScale + _DiffuseBias, _DiffuseExponent); half4 diffuse = half4(_LightColor0.rgb * atten * o.Albedo.rgb, 1.0); diffuse *= tex2D(_Ramp, float2(lambert, 0.0)); return diffuse; } |

This is called the “warped” diffuse model. By varying the scale, bias, and exponent parameters we can control the size of the shaded area, the total amount of difference between dark and light, and the sharpness of transition:

# Defining edges

The shader still lacks some indication of the object’s edge. A common way to make the edges stand out more is to add a solid outline, but this clashes somewhat with the smoothness of the shading so far. Instead, we will use rim lighting. Similarly to Lambert’s, we compute this by looking at the cosine of the angle between the surface normal and the camera. Again, we can calculate the cosine using a dot product:

\[I_r = 1 – \max\{\hat{\mathbf{v}}\cdot\hat{\mathbf{n}},0\}\]

This will produce a smooth transition from the edge of the object to it’s face, regardless of the camera’s orientation. In order to make the transition a little sharper, we raise the whole thing to a power:

\[I_r = \left(1 – \max\{\hat{\mathbf{v}}\cdot\hat{\mathbf{n}},0\}\right)^q\]

3 4 5 6 7 8 9 10 11 12 13 14 |
Properties { _BumpMap("Bump Map", 2D) = "normal" {} // shaders in order for fallback shaders to correctly work. _Ramp("Ramp", 2D) = "white" {} _Color("Color", Color) = (1.0, 0.8, 0.2, 1.0) _DiffuseScale("Diffuse Scale", Float) = 1 _DiffuseBias("Diffuse Bias", Float) = 0 _DiffuseExponent("Diffuse Exponent", float) = 1 _RimColor("Rim Color", Color) = (0.26,0.19,0.16,0.0) _RimPower("Rim Power", Range(0.5, 8.0)) = 3.0 } |

25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
uniform sampler2D _MainTex, _BumpMap, _Ramp; uniform float4 _Color, _RimColor; uniform float _DiffuseScale, _DiffuseBias, _DiffuseExponent; uniform float _RimPower; half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { // Wrapped Lambertian diffuse term float lambert = saturate(dot(o.Normal, lightdir)); lambert = pow(lambert*_DiffuseScale + _DiffuseBias, _DiffuseExponent); half4 diffuse = half4(_LightColor0.rgb * atten * o.Albedo.rgb, 1.0); diffuse *= tex2D(_Ramp, float2(lambert, 0.0)); // Rim lighting float rim_term = 1.0 - saturate(dot(normalize(viewdir), o.Normal)); rim_term = pow(rim_term, _RimPower); half4 rim = half4(_RimColor.rgb * rim_term, 1.0); return diffuse + rim; } |

This gives a little more definition to the object:

By varying the colour we can also produce interesting effects such as the alien glow on the asteroids in Space Rocks:

# Adding shininess

For shiny, glossy objects, we need a specular highlight. This is used to show a “reflection” of the light source in the surface. We will use Phong’s model as it is simple and widely used. Blinn’s model could be used and offers slightly better performance under some circumstances. To compute Phong’s specular highlight we need the vector \(\mathbf{\hat{r}}\) of the light direction reflected about the surface normal. This can be computed as \(\mathbf{\hat{r}} = 2(\mathbf{\hat{l}}\cdot\mathbf{\hat{n}})\mathbf{\hat{n}} – \mathbf{\hat{l}}\). However, Cg has a built-int reflect function. We compute the final highlight as the angle between the relfected vector and the camera. We raise the result to a “shininess” power to sharpen it (sometimes called the “Phong exponent”): \[I_s = (\hat{\mathbf{r}}\cdot\hat{\mathbf{v}})^p\]

# Results

This shader is incredibly versatile. It easily accommodated the visual style I needed for Space Rocks and seems quite versatile. It can, of course, also be used to render in TF2’s style.

# Complete Shader

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
Shader "Jonatron/NPR" { Properties { _BumpMap("Bump Map", 2D) = "normal" {} // shaders in order for fallback shaders to correctly work. _Ramp("Ramp", 2D) = "white" {} _Color("Color", Color) = (1.0, 1.0, 1.0, 1.0) _DiffuseScale("Diffuse Scale", Float) = 1 _DiffuseBias("Diffuse Bias", Float) = 0 _DiffuseExponent("Diffuse Exponent", Float) = 1 _RimColor("Rim Color", Color) = (0.26,0.19,0.16,0.0) _RimPower("Rim Power", Range(0.5, 8.0)) = 3.0 _SpecColor("Specular Color", Color) = (0.0, 0.0, 0.0, 1.0) _SpecPower("Specular Power", Range(0.5, 128.0)) = 3.0 } SubShader { Tags { "RenderType" = "Opaque" } // This allows Unity to intelligently substitute this shader when needed. LOD 200 CGPROGRAM #include "UnityCG.cginc" #pragma surface surf NPR noambient // These match the shader properties uniform sampler2D _MainTex, _BumpMap, _Ramp; uniform float4 _Color, _RimColor; uniform float _DiffuseScale, _DiffuseBias, _DiffuseExponent; uniform float _RimPower, _SpecPower; half4 LightingNPR(SurfaceOutput o, half3 lightdir, half3 viewdir, half atten) { // Wrapped Lambertian diffuse term float lambert = saturate(dot(o.Normal, lightdir)); lambert = pow(lambert*_DiffuseScale + _DiffuseBias, _DiffuseExponent); half4 diffuse = half4(_LightColor0.rgb * atten * o.Albedo.rgb, 1.0); diffuse *= tex2D(_Ramp, float2(lambert, 0.0)); // Rim lighting float rim_term = 1.0 - saturate(dot(viewdir, o.Normal)); rim_term = pow(rim_term, _RimPower); half4 rim = half4(_RimColor.rgb * rim_term, 1.0); // Phong's specular term half3 r = reflect(-lightdir, o.Normal); float phong = pow(saturate(dot(r, viewdir)), _SpecPower); half4 specular = half4(phong * _SpecColor * atten); return diffuse + rim + specular; } struct Input { // This contains the inputs to the surface function // Valid fields are listed at: // http://docs.unity3d.com/Documentation/Components/SL-SurfaceShaders.html float2 uv_MainTex; }; void surf(Input IN, inout SurfaceOutput o) { // This is where we prepare the surcace for lighting by propagating a SurfaceOutput structure half4 c = tex2D(_MainTex, IN.uv_MainTex); // Sample the texture o.Albedo = _Color.rgb * c; // Modulate by main colour o.Alpha = 1.0; // No alpha in this shader // Apply bump map o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_MainTex)); } ENDCG } FallBack "Diffuse" // Shader to use if the user's hardware cannot incorporate this one } |

You must log in to post a comment.