I've written a shader to change colour on a deformable mesh's Z position. This is to create the effect of topographic/contour maps where the colour changes based on the height of terrain of the mesh.

This is it viewed from the side

enter image description here

And viewed head-on (as it's meant to be)

enter image description here

What I need is for a black outline between two colour levels. I have done this, but the problem is that the black outline is not pixel perfect (look at the black grouping near the bottom left of the image).

My current shader simply uses if statements to determine which colour level each part should be in, and if it's within a certain threshold BETWEEN two colour layers, make it black. Obviously this is wrong.

Here's my shader code:

Shader "Unlit/MapEditorShader"
    Properties {

    _Level8Color ("Level8Color", Color) = (0.8,0.9,0.9,1)   
    _Level8 ("Level8", Float) = 30
    _Level7Color ("Level7Color", Color) = (0.8,0.9,0.9,1)   
    _Level7 ("Level7", Float) = 30
    _Level6Color ("Level6Color", Color) = (0.8,0.9,0.9,1)   
    _Level6 ("Level6", Float) = 30
    _Level5Color ("Level5Color", Color) = (0.8,0.9,0.9,1)   
    _Level5 ("Level5", Float) = 30
    _Level4Color ("Level4Color", Color) = (0.8,0.9,0.9,1)   
    _Level4 ("Level4", Float) = 30
    _Level3Color ("Level3Color", Color) = (0.75,0.53,0,1)
    _Level3 ("Level3", Float) = 20
    _Level2Color ("Level2Color", Color) = (0.69,0.63,0.31,1)
    _Level2 ("Level2", Float) = 10
    _Level1Color ("Level1Color", Color) = (0.65,0.86,0.63,1)
    _Level1 ("Level1", Float) = 0
    _Level0Color ("Level0Color", Color) = (0.37,0.78,0.92,1)
    _OutlineColor ("OutlineColor", Color) = (0,0,0,1)
    _Slope ("Slope Fader", Range (0,1)) = 0

SubShader {
    Tags { "RenderType" = "Diffuse" }
    #pragma surface surf SimpleLight
    struct Input {
        float3 customColor;
        float3 worldPos;

    half4 LightingSimpleLight (SurfaceOutput s, half3 lightDir, half atten) {
              half4 c;
              c.rgb = s.Albedo * 0.8;
              c.a = s.Alpha;
              return c;

    float _Level8;
    float4 _Level8Color;
    float _Level7;
    float4 _Level7Color;
    float _Level6;
    float4 _Level6Color;
    float _Level5;
    float4 _Level5Color;
    float _Level4;
    float4 _Level4Color;
    float _Level3;
    float4 _Level3Color;
    float _Level2;
    float4 _Level2Color;
    float _Level1;
    float4 _Level1Color;
    float _Slope;
    float4 _Level0Color;
    float4 _OutlineColor;

    void surf (Input IN, inout SurfaceOutput o) {
    //at breakpoints we want a black line
        if((IN.worldPos.z > _Level8 - _Slope && IN.worldPos.z < _Level8 + _Slope)||
        (IN.worldPos.z > _Level7 - _Slope && IN.worldPos.z < _Level7 + _Slope)||
        (IN.worldPos.z > _Level6 - _Slope && IN.worldPos.z < _Level6 + _Slope)||
        (IN.worldPos.z > _Level5 - _Slope && IN.worldPos.z < _Level5 + _Slope)||
        (IN.worldPos.z > _Level4 - _Slope && IN.worldPos.z < _Level4 + _Slope)||
        (IN.worldPos.z > _Level3 - _Slope && IN.worldPos.z < _Level3 + _Slope)||
        (IN.worldPos.z > _Level2 - _Slope && IN.worldPos.z < _Level2 + _Slope)||
        (IN.worldPos.z > _Level1 - _Slope && IN.worldPos.z < _Level1 + _Slope))
            o.Albedo = _OutlineColor;
        else if (IN.worldPos.z > _Level8)
            o.Albedo = _Level8Color;
        else if (IN.worldPos.z > _Level7)
            o.Albedo = _Level7Color;
        else if (IN.worldPos.z > _Level6)
            o.Albedo = _Level6Color;
        else if (IN.worldPos.z > _Level5)
            o.Albedo = _Level5Color;
        else if (IN.worldPos.z > _Level4)
            o.Albedo = _Level4Color;
        else if (IN.worldPos.z > _Level3)
            o.Albedo = _Level3Color;
        else if (IN.worldPos.z > _Level2)
            o.Albedo = _Level2Color;
        else if (IN.worldPos.z > _Level1)
            o.Albedo = _Level1Color;
            o.Albedo = _Level0Color;

Fallback "Opaque"

Any suggestions would be greatly appreciated.


1 Answer 1


What you're finding here is that drawing the line based on your height above/below the boundary gives inconsistent results: in places where the height changes quickly (steep slopes), the outline ends up being very narrow, or even disappearing at some zooms/camera angles. But in places where the height changes gradually, the outline gets thicker, because the surface hangs around that threshold level longer.

We can (approximately) correct for this by using screenspace partial derivatives to measure how quickly the height is changing from pixel to pixel in the neighbourhood of the pixel we're drawing, and use that to estimate how many pixels we are from the border. Then we can draw a cleanly antialiased line at a consistent pixel width, regardless of the slope, zoom, or camera angle.

I used a version of this trick to draw the topographical lines on the planetary maps in Starlink. :)

Screenshot of map in Starlink: Battle for Atlas

In Unity, the code would look something like this:

fixed4 frag (v2f i) : SV_Target
    // Compute how fast height changes between adjacent pixels, 
    // horizontally and vertically on the grid of the screen.
    float2 rateOfChange = float2(ddx(i.height), ddy(i.height));

    // Compute how far we are above/below the nearest line interval.
    float fromLine = frac(i.height * _NumberOfLines + 0.5f) - 0.5f;

    // Divide by our rate of change to get our pixel distance from the line.
    float pixelsFromLine = abs(fromLine) / length(rateOfChange);

    // Instead of a bunch of ifs and colour parameters,
    // I just painted the colour bands into a texture to sample.        
    fixed4 col = tex2D(_MainTex, float2(i.height, 0));

    // Blend between our colour and the line, based on thickness.
    col = lerp(_OutlineColor, col, saturate((pixelsFromLine - _Thickness * 0.5f) * 0.3f));

    return col;

Unity example output

This isn't 100% free of artifacts - it's still using just local information from the current shading quad, which can be misleading. And it's limited by the resolution of your height data - whether that's per-vertex or calculated per-fragment. (In the example above, I'm calculating height per-vertex, so you can see the line warble a little where the vertex resolution is too low to capture the subtlety of the curve) But it handles the bulk of cases quite well for just a few lines of code.

  • \$\begingroup\$ Wow, great answer! Yes this works likes a charm. Nice map, by the way. \$\endgroup\$
    – DeeCeptor
    Apr 1, 2020 at 22:42

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .