Easy Comic shader using Blinn Lighting
Stylization doesn’t need hefty calculations as photorealism does. Since it is all about reducing the details, we can get away from one main…
Easy Comic shader using Blinn Lighting

Jojo-posed characters with the comic shader.
Stylization doesn’t need hefty calculations as photorealism does. Since it is all about reducing the details, we can get away from one main light (or no lights at all) to make this comicy shader that is cheap to run, easy to code and use.

This tutorial will be for Unity, but it is all HLSL, so it should be transferable to other game engines. It took me about a week to learn the Blinn-Phong, access SSAO in Unity shaders, and modify everything to create this look for a game jam project, ‘Counterpoint’.
Blinn Lighting Model Basics
I will not go over the full explanation of Blinn-Phong, but I will explain at a high level the Blinn algorithm. In simple terms, it is an easier-to-calculate version of calculating the shiny reflection (specular) of light by introducing a half-vector. Why is it easy to calculate (for the GPU)? Reflection vectors are just harder since it involves a bit more complex math. By introducing the half-vector, Blinn simplifies the calculation.
Other than that, it is your regular Ambient and Diffuse lighting. And Fresnel for that rim light.
The Modifications
We will use this half-vector idea to calculate out the specular both in object space and screen space. To make the colors pop, we will simply rely on flat shading and our artist eyes to pick the right color scheme. So, no Fresnel and no Diffuse calculation.
We will add ‘Edge Map’, a hand-painted or generated map instead of Outlines or Edge Detection, to save on performance. And finally, we will read SSAO in the shader for additional details.
The Idea with all the maps and calculations

Once we are done with the calculations: Screen and Object space Specular, SSAO, and the values from the Edge Map, those values will be saturated (clamped to 0 and 1), and this will control where each of the assigned tonal maps will be displayed. This means: we will sample 2 different maps using the UVs and mask them using their respective calculated values. We will also sample the edge map, and we can add patterns to it, but I will not in my case.
This gives control over what pattern will be displayed for each instead of grayscale values, and we will lerp them. This means we will get complex overlapping patterns in places, and blurred, faint lines are gray values. We will control this with step().
The HLSL code
If you are new to shader coding, this is what a shader structure looks like.

Skeleton of basically every HLSL shader for Unity
Properties are essentially the public variables equivalent for shaders. These can be accessed via scripts and modified at runtime, and also controlled as exposed values in the materials. The structure goes:
_varName (“Display Name for Material”, type) = default value
So go ahead and fill the Properties block with the following variables.
Properties
{
_BaseColor ("Base Color", Color) = (1, 1, 1, 1)
_SpecularPower("Specular Power", Range(0, 2)) = 1
//Edge
_EdgeTex("Edge Texture", 2D) = "white" {}
_EdgeColor ("Edge Color", Color) = (0, 0, 0, 1)
_EdgeThreshold ("Edge Threshold", Range(0.0, 1.0)) = 0.95
//AO
_AOTexture("AO Texture", 2D) = "black" {}
_AOFrequency("AO Frequency", Float) = 1
_AOColor ("AO Color", Color) = (0, 0, 0, 1)
_AOThreshold ("AO Threshold", Range(0.0, 1.0)) = 0.95
//Specular
_SpecularTex("Specular Texture", 2D) = "black" {}
_SpecularFrequency("Specular Frequency", Float) = 1
_SpecularColor ("Specular Color", Color) = (0, 0, 0, 1)
_SpecularThreshold ("Specular Threshold", Range(0.0, 1.0)) = 0.95
}
After this, in the subshader, we will once again need to declare these variables plus samplers for the texture maps. Think of this as a variable declaration for the shader to HLSL to use. Which is why we do it inside the HLSLINCLUDE.
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
//Color
float4 _BaseColor;
float _SpecularPower;
float4 _BaseMap_ST;
//Shadow
float4 _SpecularTex_ST;
TEXTURE2D(_SpecularTex);
SAMPLER(sampler_SpecularTex);
float _SpecularFrequency;
float _SpecularThreshold;
float4 _SpecularColor;
//AO
float4 _AOTexture_ST;
TEXTURE2D(_AOTexture);
SAMPLER(sampler_AOTexture);
float _AOFrequency;
float4 _AOColor;
float _AOThreshold;
//Edge
float4 _EdgeTex_ST;
TEXTURE2D(_EdgeTex);
SAMPLER(sampler_EdgeTex);
float4 _EdgeColor;
float _EdgeThreshold;
ENDHLSL
Inside the Pass UniversalForward, we will now define our Attributes and Varyings. In other shader tutorials, these might have been renamed to appdata and v2f, respectively. Either works; it's simply the difference in naming it. Functionally, this is the data structure that will be passed from the vertex shader to the fragment shader. Attributes or appdata will be read from the mesh by the shader, and Varyings or v2f will be the interpolated values from the vertex going to the fragment. If this is too complicated to understand since you are early into shader development, you can simply copy and paste this part. I am sure you’ll get these bits of shader structure the more you practice.
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
//#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
//#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
//#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
//#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float3 normalOS : NORMAL;
float2 lightmapUV : TEXCOORD1;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 viewWS : TEXCOORD2;
//lightmap and shadow related data
DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 3);
float4 shadowCoord : TEXCOORD4;
};
The #pragma is essentially saying to invoke certain shader features or functions. In our case, in the vertex shader pass, we want to call the vert function, and in the fragment, the frag function. You might have noticed the Main Lights and Additional Lights, too. We don’t need them for use, which is why it's commented out. But if in Unity shaders you want Lighting information instead of calculating your own lighting, you’d need to pragma those, and include the lighting library file to use lighting functions.
Again, we don’t need to concern ourselves here with those, as we are calculating our own ‘light’ with the whole modified Blinn Lighting explained in the sections above. With that, let’s fill the vert and frag functions. This is the meat of the code that will determine your shader’s look.
Varyings vert(Attributes i)
{
Varyings v;
VertexPositionInputs positionInputs = GetVertexPositionInputs(i.positionOS.xyz);
VertexNormalInputs normalInputs = GetVertexNormalInputs(i.normalOS.xyz);
v.positionCS = positionInputs.positionCS;
v.normalWS = NormalizeNormalPerVertex(normalInputs.normalWS);
v.viewWS = GetWorldSpaceViewDir(positionInputs.positionWS);
//Lightmap and S.Harmonics things copied from Unity's Lit/SimpleLit shader
OUTPUT_LIGHTMAP_UV(i.lightmapUV, unity_LightmapST, v.lightmapUV);
OUTPUT_SH(v.normalWS.xyz, v.vertexSH);
v.shadowCoord = TransformWorldToShadowCoord(positionInputs.positionWS);
v.uv = i.uv;
return v;
}
half4 frag(Varyings i) : SV_Target
{
//normalize ofc
float3 normal = normalize(i.normalWS);
float3 view = normalize(i.viewWS);
//for specular and AO
float2 screenUV = GetNormalizedScreenSpaceUV(i.positionCS);
//-------------------------------Specular from Blinn, modifed to screenspace
//but we also check the view dir
float specular = ((0.5 - screenUV.x) * (0.5 - screenUV.x)) + ((0.5 - screenUV.y) * (0.5 - screenUV.y));
specular /= _SpecularPower;
float angleToView = max(0, dot(normal, view));
angleToView = smoothstep(0.4, 0.6, angleToView);
specular = clamp(specular, 0, 1) * angleToView;
specular = smoothstep(0.2, 0.75, specular);
//------------------------------AO
//for whatever reason, #define _SCREEN_SPACE_OCCLUSION is still not setting flag for us to get SSAO from function below
//AmbientOcclusionFactor ssaoForMe = GetScreenSpaceAmbientOcclusion(GetNormalizedScreenSpaceUV(i.positionCS));
// so I will sample ssao manually, cue copy pasta source code here
float ssao = saturate(SampleAmbientOcclusion(GetNormalizedScreenSpaceUV(i.positionCS)) + (1.0 - _AmbientOcclusionParam.x));
//even building AmbientOcclusionFactor manually
AmbientOcclusionFactor aoFactor;
aoFactor.indirectAmbientOcclusion = ssao;
aoFactor.directAmbientOcclusion = lerp(1.0, ssao, _AmbientOcclusionParam.w);
//move the AO with uv
float aoStylized = SAMPLE_TEXTURE2D(_AOTexture, sampler_AOTexture, i.uv * _AOFrequency).r;
float aoPattern = lerp(step(0.5, aoStylized), 1.0, aoFactor.directAmbientOcclusion);
//------------------------Specular Sampled
//manipulate the UV and stylize the crosshatch, mix
float specularStylized = SAMPLE_TEXTURE2D(_SpecularTex, sampler_SpecularTex, i.uv * _SpecularFrequency).r;
//specularStylized = lerp(specularStylized * 0.1f, min(1, specularStylized + 1), _LineDarkness);
float specularPattern = lerp(1.0, specularStylized, specular);
//---------------------------------Edge Map
float edge = 1.0 - SAMPLE_TEXTURE2D(_EdgeTex, sampler_EdgeTex, i.uv).r;
return step(_EdgeThreshold, edge) * step(_AOThreshold, aoPattern) * step(_SpecularThreshold, specularPattern) * _BaseColor;
}
Also, you might have noticed the two additional passes at the bottom of the shader structure. There can be multiple passes in the shader for different reasons. You can totally just copy these passes from Unity’s Lit and SimpleLit shaders. In our case, we just need DepthNormals as we want the material to write to the Depth since we use that for SSAO. ShadowCaster is not needed as we have not used Lighting functions, but if we did, you’ need this pass for the object to case shadows.
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
HLSLPROGRAM
#pragma vertex ShadowPassVertex
#pragma fragment ShadowPassFragment
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
ENDHLSL
}
//needed for AO
Pass
{
Name "DepthNormals"
Tags { "LightMode"="DepthNormals" }
ZWrite On
ZTest LEqual
HLSLPROGRAM
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
// Material Keywords
#pragma shader_feature_local _NORMALMAP
//#pragma shader_feature_local _PARALLAXMAP
//#pragma shader_feature_local _ _DETAIL_MULX2 _DETAIL_SCALED
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local_fragment _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
// GPU Instancing
#pragma multi_compile_instancing
//#pragma multi_compile _ DOTS_INSTANCING_ON
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthNormalsPass.hlsl"
ENDHLSL
}
So this should suffice to make the complete shader.
메타데이터
- post_id
- 4b6cf063e424
- slug
- easy-comic-shader-using-blinn-lighting-4b6cf063e424
- url
- https://medium.com/@prasinshrestha/easy-comic-shader-using-blinn-lighting-4b6cf063e424
- canonical_url
- https://medium.com/@prasinshrestha/easy-comic-shader-using-blinn-lighting-4b6cf063e424
- author_url
- https://medium.com/@prasinshrestha
- status
- ok
- fetched_at
- 2026-07-11 21:00:18