POLYMASON

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 neither is available: the models come from somewhere you do not control, or you want to switch the look on and off, or the mesh is 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 is 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, and the materials article covers the colour half of the look.

The idea

A face's normal is perpendicular to the face. The GPU knows, for every pixel it draws, 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. Two vectors in the plane of the face, crossed, give a vector perpendicular to it. Normalise that and you have the face normal, 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. If the model lights from the wrong side, swap the two arguments. Unity's derivative convention is consistent within a platform but the y direction differs between Direct3D and OpenGL, so a shader that will run on both should use UNITY_NEAR_CLIP_VALUE or test on both, or simply multiply by -1 where the platform macro says so; in practice one order works across desktop platforms and the other on some mobile ones, and it is worth a check on the target.

Built-in render pipeline

A minimal surface shader that lights flat, keeps a colour or a texture, and casts shadows:

Shader "PolyMason/Flat" {
  Properties {
    _Color ("Color", Color) = (1,1,1,1)
    _MainTex ("Texture", 2D) = "white" {}
  }
  SubShader {
    Tags { "RenderType"="Opaque" }
    CGPROGRAM
    #pragma surface surf Lambert vertex:vert
    struct Input { float2 uv_MainTex; float3 worldPos; };
    fixed4 _Color; sampler2D _MainTex;
    void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); }
    void surf (Input i, inout SurfaceOutput o) {
      float3 n = normalize(cross(ddy(i.worldPos), ddx(i.worldPos)));
      o.Normal = mul((float3x3)unity_WorldToObject, n);   // tangent-space output expects object space here
      o.Albedo = tex2D(_MainTex, i.uv_MainTex).rgb * _Color.rgb;
    }
    ENDCG
  }
  FallBack "Diffuse"
}

Surface shaders expect o.Normal in tangent space, which is why the normal is transformed; for a mesh with no tangents, a plain vertex/fragment shader that lights in world space is cleaner, and does not need the transform.

URP

In URP, write a vertex/fragment shader with HLSL and do the lighting yourself, or use Shader Graph. The fragment part is the same idea:

float3 n = normalize(cross(ddy(input.positionWS), ddx(input.positionWS)));
Light light = GetMainLight();
float ndl = saturate(dot(n, light.direction));
float3 colour = albedo * (light.color * ndl + SampleSH(n));

SampleSH gives the ambient from the scene's light probes, which keeps the shaded sides from going black. Add TransformWorldToShadowCoord and GetMainLight(shadowCoord) if the model should receive shadows, and a ShadowCaster pass for it to cast them; 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 output Normal (Vector3), and the body:

Normal = normalize(cross(ddy(PositionWS), ddx(PositionWS)));

Wire the output into the fragment stage's Normal (World Space) input; 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, in URP and HDRP.

What it costs

Two derivative instructions and a cross product per pixel, which is nothing beside the lighting itself. There is no extra memory and no change to the mesh. The one real cost is that it runs on every pixel of every model using the material, so a model that is already flat gets the work done twice, harmlessly.

Caveats

  • Normal maps do nothing. The shader has replaced the normal, so a normal map has nothing to perturb. For a flat-shaded model that is the point; for a model that mixes the two, this is the wrong tool.
  • Smooth models go faceted. Every triangle becomes a flat plane, so a smooth sphere becomes a faceted one. Put the material only 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; it is invisible in practice, and an edge-aware version is more trouble than it is worth.
  • Transparency. On a material that writes no depth, the world position is still correct, so the shader works; on a two-sided material, flip the normal when facing says the back is showing, or the back faces light from the wrong side.

When to use which fix

The file with flat normals is the fix that costs nothing and works everywhere, and it is the one to use when you control the export. The importer is the fix for a model you have and cannot re-export. The shader is for when you want the look independent of the mesh: runtime-generated geometry, a toggle between flat and smooth, or a project where models arrive from many sources and you would rather not police each one.

More in this guide

Or build a tree and see what comes out.