A flat shading shader for Unity
Part of Flat shading in Unity for low poly models
The importer can make a model flat, and the file can carry flat normals, but sometimes you can't use either. The models might come from somewhere you don't control, you might want to switch the look on and off, or the mesh might be generated at runtime. A shader can shade flat on its own, from geometry it works out per pixel, and it costs nothing you can measure. It's the third of the three fixes in flat shading in Unity. The first two, the importer's smoothing angle and a file with flat normals, are simpler when you can use them. The materials article covers the color half of the look.
The idea
A face's normal is perpendicular to the face. For every pixel it draws, the GPU knows how the world position changes from one pixel to the next along the screen's x and y. Those are the screen-space derivatives, ddx and ddy in HLSL. Both are vectors in the plane of the face, so crossing them gives a vector perpendicular to it. Normalize that and you have the face normal. It's the same for every pixel on the face, whatever the mesh's vertex normals say.
float3 n = normalize(cross(ddy(i.worldPos), ddx(i.worldPos)));
The order of the cross product sets which way the normal points, and the right order depends on which way screen y runs. That differs between Direct3D and OpenGL, and in Unity it also flips when a camera renders into a texture. So don't rely on the order. Turn the normal to face the camera:
n *= sign(dot(n, _WorldSpaceCameraPos - i.worldPos));
A face you can see always faces the camera, so this holds on every platform. On a two-sided material it also lights the back of a leaf card correctly.
Built-in render pipeline
This is a complete shader for the Built-in pipeline. It lights flat from the main directional light and the scene's ambient light, keeps a color or a texture, and receives shadows. The FallBack gives it a shadow caster pass, so it casts them too. It was compiled and rendered in Unity 6 to confirm that it lights the correct side of a model.
Shader "PolyMason/Flat" {
Properties {
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
Pass {
Tags { "LightMode"="ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
};
fixed4 _Color; sampler2D _MainTex; float4 _MainTex_ST;
v2f vert (appdata_base v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
TRANSFER_SHADOW(o)
return o;
}
fixed4 frag (v2f i) : SV_Target {
float3 n = normalize(cross(ddy(i.worldPos), ddx(i.worldPos)));
n *= sign(dot(n, _WorldSpaceCameraPos - i.worldPos));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed ndl = saturate(dot(n, _WorldSpaceLightPos0.xyz));
fixed3 light = _LightColor0.rgb * ndl * SHADOW_ATTENUATION(i) + ShadeSH9(half4(n, 1));
return fixed4(albedo * light, 1);
}
ENDCG
}
}
FallBack "Diffuse"
}
It's a plain vertex and fragment shader because it has to light in world space. A surface shader would be shorter, but surface shaders expect o.Normal in tangent space, and a world-space normal written there lights the wrong side of the model. This shader handles one directional light. Point and spot lights need a ForwardAdd pass with the same two lines for the normal.
URP
In URP, either write a vertex/fragment shader with HLSL and do the lighting yourself, or use Shader Graph. The fragment part uses the same idea:
float3 n = normalize(cross(ddy(input.positionWS), ddx(input.positionWS)));
n *= sign(dot(n, GetWorldSpaceViewDir(input.positionWS)));
Light light = GetMainLight();
float ndl = saturate(dot(n, light.direction));
float3 color = albedo * (light.color * ndl + SampleSH(n));
SampleSH gives the ambient light from the scene's light probes, which keeps the shaded sides from going black. If the model should receive shadows, add TransformWorldToShadowCoord and GetMainLight(shadowCoord). For it to cast them, add a ShadowCaster pass. URP's own Lit shader source has both to copy from.
Shader Graph
Add a Custom Function node. Set its type to String. Give it an input PositionWS (Vector3) wired from a Position node set to World, an input ViewDirWS (Vector3) wired from a View Direction node set to World, and an output Normal (Vector3). The body is:
Normal = normalize(cross(ddy(PositionWS), ddx(PositionWS)));
Normal *= sign(dot(Normal, ViewDirWS));
Wire the output into the fragment stage's Normal (World Space) input, and set the graph's fragment normal space to World in Graph Settings. Everything else in the graph stays as it was. This works in Lit and Unlit graphs, and in both URP and HDRP.
What it costs
The cost is two derivative instructions and a cross product per pixel, which is tiny next to the lighting itself. There's no extra memory and no change to the mesh. It does run on every pixel of every model that uses the material, so a model that's already flat gets the same work done a second time, which does no harm.
Caveats
- Normal maps do nothing. The shader has replaced the normal, so a normal map has nothing to perturb. On a flat-shaded model that's what you want. For a model that mixes flat shading with normal maps, this shader is the wrong choice.
- Smooth models go faceted. Every triangle becomes a flat plane, so a smooth sphere turns into a faceted one. Only put the material on models that should be flat.
- Derivatives are per 2x2 pixel block. Along a face's silhouette the derivative can sample across the edge and give a wrong normal for one pixel. You won't see it in practice, and an edge-aware version is more trouble than it's worth.
- Transparency and two-sided materials. On a material that writes no depth, the world position is still correct, so the shader works. On a two-sided material, the line that turns the normal toward the camera already lights the back of a face correctly.
When to use which fix
A file with flat normals costs nothing and works everywhere, so use that fix when you control the export. The importer is the fix for a model you already have and can't re-export. Use the shader when you want the look to be independent of the mesh. That covers runtime-generated geometry, a toggle between flat and smooth, and projects where models arrive from many sources and you'd rather not police each one.
More in this guide
- Flat shading in Unity for low poly modelsWhy a low poly model goes smooth when it lands in Unity, what the importer's Normals and Smoothing Angle settings do, the three places you can fix it, and how to keep the colors flat as well. Three articles underneath go into the detail.
- Unity's smoothing angle and Model tab normalsWhat the Smoothing Angle in Unity's model importer does, how the Normals, Normals Mode and Weld Vertices settings affect it, which values give flat shading, and how to set them once for a whole folder with an importer preset.
- Materials for low poly models in UnityThe three ways a low poly model can carry color and what each costs in draw calls and setup. How to set up a palette texture material in Built-in and URP with the right filtering, and how batching and instancing change the choice for a forest.
Or build a tree and see what comes out.