Unity Toon Shader Tutorial (2023)

May 30, 2023

On this page

    What you'll learn

    In this tutorial, you will learn how to write a lit toon shader. You will write a custom shader for Unity URP using HLSL. The shader will receive lighting from a single direction light, and it will cast and receive shadows. This tutorial is based on Unity 2021.3 LTS.

    Who am I?

    Hi! I’m Michael, the developer behind OccaSoftware. I am writing this tutorial to help you learn about shaders and improve your Unity skills. I want your game to be a success, so I’m happy to do whatever it takes to help empower you and help you build your project.

    As the developer for OccaSoftware, I created three toon shader assets for Unity that fit different use cases and different types of projects. And, I developed more than 30 assets for Unity. More than 20,000 game developers use my game assets to help players fall in love with their games. If you want to learn about custom shading in Unity, I’m your guy.

    Introduction to Toon Shading

    So what is Toon Shading anyway?

    Toon shading is a type of expressive rendering. You can most easily understand expressive rendering in contrast to photorealistic rendering. Photorealistic rendering aims to accurately render materials as the human eye would perceive it using physically-based rendering algorithms. In contrast, expressive rendering aims to enable art-driven rendering to achieve specific visual styles. 

    Toon shading is also sometimes called cel shading. According to Wikipedia, the name cel shading “comes from cels (short for celluloid), clear sheets of acetate which were painted on for use in traditional 2D animation”. I’ll just say that this was before my time and move on.

    Toon shading is most often used to mimic the look of traditional cartoons, comics, or animations. This shading style is characterized by having a limited number of discrete shades of color to create a flat look with blocks of color. Toon shading typically features some specular highlights as well as rim lighting.

    Outline Shaders

    In many projects that use toon shading, artists also often use outline shaders to further sell the cartoon look. Outline rendering is a complex topic on its own, so it is outside of the scope of this tutorial. You will typically see toon shading applied only on character models with level geometry rendered using traditional shading techniques.

    Examples of Toon Shading

    One of the most famous historical examples of toon shading is Jet Set Radio. I loved the Monster Rancher series, so I’ll also give a shout-out to Monster Rancher 3. More recent examples include Astral Chain, Genshin Impact, and The Legend of Zelda: Tears of the Kingdom. In my opinion, toon shading is the best fit for games that want to communicate an anime or cartoon-inspired aesthetic.


    For this tutorial, I will assume that you are using Unity 2021.3 LTS using the Universal Render Pipeline. 

    This tutorial is for writing shaders in Unity URP, which I think of as the default render pipeline as of this writing. If you don’t have the Universal Render Pipeline installed in your project, install it now. 

    This tutorial is designed for the Forward Rendering path. Technically, it “just works” with Deferred Path as well because we will use the ForwardOnly lightmode.

    I also assume that you are fairly comfortable using the Unity engine and that you have a basic understanding of programming.

    Set up your project

    Before we start writing our shader, we will set up a simple test environment.

    1. Create a new scene and open it.
    2. Add a plane, center it on the origin.
    3. Add a sphere, set the world position to (0, 1, 0).
    4. Move your camera from (0, 0, -10) to (0, 0, -3) so that we can see our sphere a little better.
    5. Create a new material in your Project folder, then apply it to the sphere.

    This is what my test environment looks like:

    Note the newly-created Toon Shader Material asset in the Project view (and applied to the Sphere). We will come back to this material later.

    (and ignore my messy project directory, thank you).

    Write the shader boilerplate

    Now that we have created our test environment, we will create our shader file, write some boilerplate to get our shader off the ground, and apply it to the material.

    Create your shader file

    In your project directory, create the shader file. I normally use the Unlit Shader type as my default. Open it in your text editor of choice. Fortunately, Unity has populated our shader file with all of the boilerplate we need, so we’re already done with this section.




    Sorry, I lied. :) 

    Unity did populate the shader with what appears to be helpful boilerplate. Unfortunately, the code in the shader is applicable to the Built-In Render Pipeline. We are not using the Built-In Render Pipeline for this tutorial. Therefore, this code is 100% useless to us. 

    We will need to write our own boilerplate.

    Fortunately, I made boilerplate files for you that you can download from Github Gists and import directly to your project.

    (Now is when you say “Thanks, Michael!”).

    Download the following two files from Github now.

    Our boilerplate consists of two files. A .shader file and a .hlsl file. Our .shader file has #include directives that cause the contents of our .hlsl file to be automatically imported to our .shader file.

    We structure our files this way because it makes it easier for us to split up our boilerplate shader pass setup and #pragma directives from our core shader functions.

    It also makes it easy for us to share the same core shader functions across multiple shader passes.

    In this boilerplate setup, we have four shader passes. If we write our code inline, we need to copy and paste our code in four separate places - once for each pass. By using the #include approach, we only have to write our code once.

    Understanding the Shader Boilerplate

    At a high level, Shaders consist of the following elements:

    I will not go into detail on each of these elements as part of this tutorial, but just be aware that they exist and that I set all of this up for you in the boilerplate downloads.

    Also be aware that we will need to go back and add new properties to the Properties block, which means we also need to add new properties to the CBuffer block as well. 

    Take a few minutes to explore the .shader and .hlsl files and familiarize yourself with them. I wrote dozens of comments to explain the purpose of nearly every line of code, so take a look.

    Getting Started with Lighting

    Finally! We are done setting up the scene and importing our boilerplate. Now, we can have some fun with introductory concepts to lighting.

    Let’s take a step back and imagine time and space before the big bang. Everything was dark. No stars, no moons, no planets, no atmospheres, no rocks, no nothing.

    Now imagine that you create a perfectly round, perfectly white sphere, similar to the one that we have in our scene view right now. What would you see?

    Well, nothing. Everything around you is black. No light is hitting the sphere because there is no light in this universe. The sphere is technically white. But without any light to illuminate it, it appears completely black. You can touch it, but you can’t see it. This is what happens without lighting.

    Diffuse Lighting

    Now imagine that we create a star, our sun, and lob it pretty far away. This star is bright, so the light from this star is hitting our sphere. The rest of the universe is still completely black, since there is nothing for the light from this sun to bounce off of. 

    But the sphere, well half of it at least, is now illuminated with the light of the sun. You can now see that the sphere is white. The sphere is most bright at the point that is directly facing the sun, and then gradually becomes darker until we get halfway round the sphere, at which point it is completely dark. This half, the half facing away from the sun, is still in complete darkness - no light is hitting it.

    How can we think about this phenomenon? Each point on the surface of the sphere has a normal vector. The normal vector is the direction that is perpendicular to the surface for each point.

    The normal vectors of a warped plane:

    The normal vectors of a sphere:

    When the normal vector on our sphere is perfectly opposite to the direction of the sun’s light rays, let’s say that the sphere is receiving 100% of the light from the sun. When the normal vector on our sphere is perpendicular to the direction of the sun’s light rays, then let’s say that our sphere is receiving 0% of the light from the sun. This is the intuition for the diffuse component lighting attenuation model that we will use later.

    Accounting for the Color of Light

    Now imagine that we tinted the sun red. What color would the sphere appear to be? To your eyes, the red light of the sun would appear to tint the white surface of the sphere so that the sphere also now looks red. It is like we multiplied the color of the sphere by the color of the sun. This is the intuition for how we will blend the light color with the material color later on. Materials in the real world have a base color that is neither perfectly black nor perfectly white, but rather fall into a range of values in between.

    Specular Lighting

    The light also has a specular component to it. We won’t go into the math here, but think about it like this. Assume that the ball is a little bit reflective. There will be some direction between your eyes and the ball where the light from the sun is reflected directly from the ball to your eyes, resulting in a bright highlight. This is what we call a specular term.

    Ambient Lighting Term

    Now imagine that we surround ourselves, the sun, and the sphere - the entire universe - with an infinitely large sphere, and that this sphere emits some color of light. This color will apply evenly to all parts of the sphere. This is what we will use for our ambient term.

    Wrapping Up

    So, we’ve introduced the intuition for how we will approach diffuse lighting, specular lighting, ambient lighting, and how we will account for the light’s intrinsic color. Now we will get into the meat of the coding. We’ll start by setting up directional lighting and shadows, we’ll add specular and rim lighting, make some quality improvements, and then incorporate ambient lighting to finish up.

    Setting up Directional Lighting

    The first thing we will add for our shader is directional lighting. Let’s focus on the Fragment function in our ToonShaderPass.hlsl.

    To add directional lighting, we will need a couple of pieces about the object and the material at this position.

    • What is the material’s color?
    • What is the normal vector normal vector?
    • What is the light’s color and direction?

    We will work through these problems one-by-one.

    Getting the Material’s Color

    In our Properties and CBuffer, we defined a _Color property (a color) and a _ColorMap property (a texture). We can use these properties directly and update the Fragment shader like so:


    Your sphere will now render the same color as the input, effectively acting as an unlit material.

    Getting the Main Light Properties

    Next, we need to get the main directional light (and hopefully some additional information about it as well).

    To get the main light, we can use one of the helper methods that I included in the ToonShaderPass.hlsl file: [.c]GetMainLightData(float3 PositionWS, out Light light)[.c].

    This method expects a world-space position as the first argument and a light struct as the second argument.

    Our Varyings struct contains the world-space position, but we need to create a variable for the light struct and pass that in, like so:


    This method populates the light data for us. The Light struct contains information about the light, including the direction, color, and shadow attenuation.

    Using the Main Light Data

    We will use the light color and light direction to set up the directional lighting.

    We need to compute the extent to which the main directional light is illuminating each point on the surface of the sphere. Remembering our intuition from earlier, we need to figure out the extent to which the normal vector and light direction vector are aligned. We need the dot product, which tells us how much two vectors point in the same direction. 

    Fortunately, there’s a handy intrinsic method built into HLSL that can calculate this for us: dot(x, y). We can use the XoY notation (read as X dot Y) to denote a variable that is a result of a dot product. In this case, we call the variable NoL (N dot L) to represent the dot product of the Normal and Light vectors.


    Our sphere is now lit according to the intuition that we formed earlier.

    We also incorporated the main light’s color. Your sphere might look a little yellow now depending on the Directional Light’s color in your scene.

    Take a few minutes to play with the light intensity, light color, and light position to see how it affects the sphere.

    Switching to Toon Rendering

    Of course, toon shading doesn’t look like this, so we need to take a few more steps to make sure that we render in the toon style. The classic toon style has a sharp cut-off between “lit” and “unlit” positions on the object.

    To achieve this look, we are going to use the step(y, x) HLSL intrinsic. Step compares x and y. If x is greater than or equal to y, it returns 1, otherwise it returns 0. In our case, we know (and can infer from the lighting we have) that NoL is > 0 for all normal vectors in the direction of the light source and NoL is < 0 for all normal vectors pointing away from the light source.

    So, we will use [.c]step(0, NoL)[.c] to give us a value of (NoL > 0 ? 1 : 0) => 1 for all points facing the light source and a value of 0 for all points facing away from the light source.

    Calculate this using a new float variable called toonLighting, then replace NoL in our lighting calculation with our new toonLighting variable.


    Congratulations! You’ve successfully set up your first toon directional lighting shader. Next up, we will work on shadows.

    P.S. The edge where light turns dark is a little pixelated. Don’t worry - we will revisit this artifact later on in our quality pass.

    Receiving Shadows

    You may have observed that your sphere is already casting shadows. This is thanks to the ShadowCaster pass that I included in our boilerplate. This is great, but we need to do a little extra work to make sure that our sphere can receive shadows and that we render these shadows in that signature toon style.

    To make sure that we have an appropriate test environment to program our receiving shadows functionality, let’s make one small change to our demo scene. Simply add another sphere in the scene in a position where it would cast shadows on the toon shaded one. You can know that the sphere would cast shadows on the toon shaded one if the two shadows on the plane overlap.

    What’s going on here? Why is our sphere casting shadows but not receiving any?

    The reasoning is that shadow casting happens during the Shadow Caster pass, which I’ve set up for you in our boilerplate. During the Shadow Caster pass, Unity basically renders a depth texture containing all the opaque objects in your scene. This texture is called a Shadow Map. 

    Meanwhile, the shadow receiving happens during the material’s rendering pass (here the ForwardOnly pass). To receive shadows, each material samples that Shadow Map and determines whether it is in shadow or not. If it’s in shadow, then it shades itself. This is what we are going to set up.

    For us, we can get the results of that Shadow Map sample as part of our light variable. The Light struct includes a shadowAttenuation variable, which represents the shadows that should be applied to our sphere. To incorporate this into our lighting, it’s as simple as multiplying our return value by the light.shadowAttenuation variable.


    Great! Only one problem - it’s not in the toon style.

    At this point, it’s a simple matter of re-using what we learned from making NoL in the toon style. We can re-use the step function to make sure that shadows have this hard edge as well.


    In this case, we create a new float variable, toonShadows. Then, we call the step function and pass in 0.5 as the y arg and [.c]light.shadowAttenuation[.c] as the x arg. 

    Why 0.5? You can see from the traditionally lit shadows before that the shadow demonstrates a gradient going from 0 (fully shadowed) to 1 (fully not shadowed). For our toon shader, we want the cut-off to be somewhere along this gradient rather than having our shadows end too early or too late.

    Our directional shadows are now working! Next, we will set up some advanced lighting characteristics: specular lighting and rim lighting.

    Specular Lighting

    In this section, we will add a specular lighting term to our toon shader.

    Like we reviewed earlier, you can think of the specular lighting term like the light is bouncing off our shiny sphere and being reflected to your eyes. To estimate this, we need to figure out “the direction the surface normal would need to be facing in order for the viewer to see a specular reflection from the light source”. This is commonly referred to as the half-vector.

    The half-vector represents the vector halfway between the light direction and the viewer’s eyes. It can be calculated as the normalized result of the light direction and view direction. 

    In other words:

    [.c]float3 halfVector = normalize(light.direction + viewDirectionWS);[.c]

    To calculate this vector, we need two pieces of information:

    • The light direction (which we have), and
    • The view direction (which we don’t have yet).

    So, we need to calculate the view direction, then form the half vector, then use the half vector to create our specular term.

    Get the View Direction and Create the Half Vector

    We need to add the viewPositionWS variable to the Varyings struct so that we can pass it to the Fragment function. Do that now by adding this line to your Varyings struct:

    [.c]float3 viewDirectionWS : TEXCOORD3;[.c]

    Next, we need to calculate the value for this variable in the Vertex function. We will use the GetWorldSpaceViewDir(positionWS) method to calculate this value. We want to make sure that the output value is normalized, so we also normalize it. Do this now by adding this line to your vertex function after you have calculated the OUT.positionWS variable.

    [.c]OUT.viewDirectionWS = normalize(GetWorldSpaceViewDir(OUT.positionWS));[.c]

    Finally, we need to calculate the half vector in the fragment function. We can create this vector by summing the light direction and the view direction, then normalizing the result. This is like averaging the two vectors. Do this now by adding the following line to your fragment function (before the return, obviously :)).

    [.c]float3 halfVector = normalize(light.direction + IN.viewDirectionWS);[.c]

    I summarized these changes in context in this gist: https://gist.github.com/occasoftware/04e067d34ffd2bf6f7f6a359a296e055

    Calculating NoH

    Now that we have our half vector, we can calculate our specular term and apply it to the material.

    Reusing our new knowledge of the dot product, we can calculate the alignment between the surface normal and our half vector by calculating the NoH. In this case, it’s actually important to make sure that our NoH is >= 0. So, we will also use the max(x, y) intrinsic

    [.c]float NoH = max(dot(IN.normalWS, halfVector), 0);[.c]

    Now that we have calculated the NoH, we can use it to evaluate our specular term.

    Thinking about the Specular Term

    Let’s take a second to talk about the specular term. Where should the specular lighting appear? If the position is in shadow, then there should logically have no specular lighting. If the position is facing away from the light source, we should also have no specular lighting, since there is no light hitting the surface for it to reflect. 

    We need to incorporate our shadow term and our NoL terms in our specular equation. Just like our diffuse lighting, we also need to incorporate the light color.

    Finally, we need some property to help us control the falloff for the specular term. This property is often called Smoothness, Glossiness, or Roughness (which is just 1 - Smoothness). We will call our property smoothness.

    We will start by adding this new property to our Shader Properties and our CBUFFER, then we will calculate our specular term, and finally we will do a little clean-up.

    Adding Smoothness to our Shader Properties and CBUFFER

    To add Smoothness to your Shader Properties, re-open your ToonShader.shader file. Look for the Properties section near the top, then add our new smoothness property just after the _Color property. 

    [.c]_Smoothness ("Smoothness", Float) = 16.0[.c]

    We also need to add our new smoothness property in the CBUFFER block. So, go back to your ToonShaderPass.hlsl and locate it. Then, add the _Smoothness property like so:

    [.c]float _Smoothness;[.c]

    Your Property block and CBUFFER blocks should look like this now:


    Calculate our Specular Term

    To calculate the specular term, we need to incorporate the shadow and NoL terms as well.

    I think it makes the most sense to calculate the specular term:

    [.c]float specularTerm = pow(NoH, _Smoothness * _Smoothness);[.c]

    Then, we can attenuate this term using our toonShadows and toonLighting variables:

    [.c]specularTerm *= toonLighting * toonShadows;[.c]

    To toon-ify it, we re-use the step function using a small number together with our specularTerm:

    [.c]specularTerm = step(0.01, specularTerm);[.c]

    Finally, we combine it into the final lighting function by adding it with the directional lighting.

    [.c]return _Color * SAMPLE_TEXTURE2D(_ColorMap, sampler_ColorMap, IN.uv) * (toonLighting * light.color * toonShadows + specularTerm * light.color);[.c]

    Your fragment method should look like this now:


    If you reviewed the code carefully, you’d spot that we also added two normalize calls up towards the top there.

    What gives?

    Normalizing the Normal and View Direction in the Fragment function.

    You may have noticed some shading artifacts with the sphere while we have set up our specular term.

    These shading artifacts come about when we use the interpolated normal and view direction values from the vertex stage without normalizing them first.

    Resolving this is fairly simple. In your fragment stage, simply normalize the two values and re-assign them back.

    [.c]IN.normalWS = normalize(IN.normalWS);[.c]

    [.c]IN.viewDirectionWS = normalize(IN.viewDirectionWS);[.c]

    Make sure you normalize these values before using them elsewhere in your Fragment shader stage.

    Wrapping up the Specular Term

    Our specular term calculation is all set. But, our return call is getting a little messy. Let’s simplify it by pre-calculating our surface color and final lighting data, then combining just the two at the end.

    [.c]float3 surfaceColor = _Color * SAMPLE_TEXTURE2D(_ColorMap, sampler_ColorMap, IN.uv);
    float3 directionalLighting = toonLighting * toonShadows * light.color;
    float3 specularLighting = specularTerm * light.color;
    float3 finalLighting = float3(0,0,0);
    finalLighting += directionalLighting;
    finalLighting += specularLighting;
    return surfaceColor * finalLighting;[.c]

    You can see the full Fragment function here: 


    Next up, we’ll add in Rim lighting.

    Rim Lighting

    Rim lighting refers to the practice of adding extra light to the edges of an object to create the illusion of reflected light or backlighting. This technique is particularly beneficial for toon shaders since it enhances the object's shape against the smooth, shaded backgrounds. 

    To identify the "rim" of an object, we want to find the surfaces that are pointed “least towards” the camera. To calculate this, we’ll re-use the dot product. This time, we’ll use the normal direction and view direction to find the alignment between your eye and the surface. The less aligned they are, the more rim lighting we want, so we will flip the result ( 1 - result ).

    Calculating the Rim Term

    We’ll simply take the dot product of the normal and view direction, raise the inverted value to a power, apply the toon lighting and toon shadows terms to make sure that we don’t draw the rim areas that aren’t receiving any light, then use our step function to make the result cohere with our toon shading model. 

    Note that you also need to add the _RimSharpness float property to your Shader Properties and CBUFFER. I’ll leave that to you as a little challenge. If you get stuck, revisit how you did it for the _Smoothness property.

    [.c]float NoV = max(dot(IN.normalWS, IN.viewDirectionWS), 0);
    float rimTerm = pow(1.0 - NoV, _RimSharpness);
    rimTerm *= toonLighting * toonShadows;
    rimTerm = step(0.01, rimTerm);[.c]

    Integrating the Rim Term

    Next, we want to multiply the rim term by a new _RimColor property, then add it to the final lighting variable so that it is included in our output. 

    Add the new _RimColor in your Shader Properties as an HDR Color.

    [.c][HDR] _RimColor ("Rim Color", Color) = (1.0, 1.0, 1.0)[.c]

    And in your CBUFFER as a float3:

    [.c]float3 _RimColor;[.c]

    Then, in your Fragment function, create a new float3 variable called rimLighting and set it to the rim term multiplied by the rim color.

    [.c]float3 rimLighting = rimTerm * _RimColor;[.c]

    Finally, add your rim lighting to your final lighting variable:

    [.c]finalLighting += rimLighting;[.c]

    We covered a lot of ground here, so here are up-to-date versions of the .shader and .hlsl files for you to reference if you get stuck.

    The .shader file: https://gist.github.com/occasoftware/ed9f409cbfa75b2c5c182cef8bc2f8fb

    The .hlsl file: https://gist.github.com/occasoftware/142e27268b850e7bea073abb4b8a7399

    Improving our Directional Lighting

    Our lighting is still a little rough around the edges. You can see clear pixel edges everywhere. Although anti-aliasing can help somewhat to smooth these edges out, it’s better to provide cleaner results out of the gate.

    In this section, we will replace our step() calls with smoothstep() function calls that give us a tight gradient.

    Normally, we want our smoothstep to look something like smoothstep(min, min + y, x), where y is some small number like 0.01. We can create a nifty helper function to make this super easy to apply to all our current step calls.

    Add a new method in your ToonShaderPass.hlsl,

    [.c]float easysmoothstep(float min, float x)
    return smoothstep(min, min + 0.01, x);

    Then replace each instance of [.c]step(y, x)[.c] with [.c]easysmoothstep(y, x)[.c].

    Poof! Your hard edges are now (mostly) gone.

    Setting up Ambient Lighting

    What is ambient lighting?

    Ambient lighting is crucial for toon shading. As you’ve seen so far, accounting only for direct lighting causes us to have black shadows and dark regions. That’s not quite the look we are going for.

    So, we’ll introduce an ambient lighting term to help alleviate the dark regions.

    Ambient lighting creates an overall, uniform light level throughout the level and will be applied evenly to all parts of the material.

    Lighting is additive, so we add it together to our finalLighting term, then multiply it with the surfaceColor like the other lighting information.

    I know what you’re about to ask: Doesn’t this mean that pure black materials stay black?

    The answer is that, yeah, they do stay black. In fact, that’s how pure black diffuse materials also work in the real world, too. In real life, even the darkest materials (coal) have an albedo of around 0.04.

    Should we use the skybox color or a uniform color?

    To set up the ambient lighting, we will use a uniform color for the entire world to keep with our toon shading goals. If we wanted to use the world skybox color, we could just use SampleSH() and pass in the normal direction. If we used the skybox color, our mesh would look like this:

    Ok, how do I set up the uniform color?

    The approach we will take is easy and gives you a lot of control over each material.

    Add a new Color property in your Toon Shader.shader Properties. Name it [.c]_WorldColor[.c]. Then, add the same property in your UnityPerMaterial CBUFFER block.

    Finally, combine this property with the finalLighting term by adding it together like this:

    [.c]finalLighting += _WorldColor;[.c]

    I went ahead and added a Global Volume with Bloom and Tonemapping to make sure we can still see our object since we are working in linear color space.

    You can adjust the world color from the material properties in the inspector.

    Your sphere should look like this now:

    The Final Shader

    If you have made it this far:


    This was a long tutorial, and I’m proud of you for taking the time to learn something new.

    The final .shader and .hlsl files are on Github:

    Need help? Have feedback?

    If you have questions, get stuck, or see a mistake, just let me know. You can reach me by email at michael@occasoftware.com or join our Discord.

    What should I read next?

    Great question! Try checking out our other write-ups on the blog. If you liked this tutorial, you might like our comparison of URP, HDRP, and Built-In Render Pipelines, our tutorial on how to change your skybox in Unity, or you might want to find some great game development communities.


    This tutorial was designed to help you learn about shaders and learn about toon shading in Unity. Given that context, the shaders discussed here are licensed under CC-BY-NC-SA. You can credit OccaSoftware, this blog post, and/or the website directly.

    Michael Sacco
    Founder & CEO

    Michael Sacco is the Founder and CEO of OccaSoftware where he specializes in developing game assets for Unity game developers. With a background ranging from startups to American Express, he's been building great products for more than 10 years.

    technical art

    Recommended Articles