Why do my territory borders look awful?

In my game, I have a bunch of territories. These territories have a shader that allows me to draw an outline around the border of the territory. The outline is inside the edge of the territory. I can control many settings, such as the border color, border thickness, etc.

I do a pathfind from the selected territory to the one I’m pointing at and I hilite each territory that is part of the path by drawing its border in yellow instead of black. (I hilite the last territory in white instead of black).

So, this works but the hilite looks terrible. Most of the time the border is sort of a crosshatch pattern on black and yellow or black and white. In a few places, the line comes through as a solid yellow, which looks a lot better but is still not the best.

How do I go about fixing this? I don’t know if it makes any difference, but I’m developing this for a Quest 2.

I’m attaching the shader I use and a video showing the problem.

Shader "ConquestVR/InternalLine"
{
    Properties
    {
        _FillColor("Fill Color", Color) = (1,0,0,0)
        _BorderColor("Border Color", Color) = (0,0,0,1)

        _HilitedFillColorScale("Selected Color Scale", Range(0.5, 1)) = 0.9
        _HilitedBorderColor("Hilited Border Color", Color) = (0,0,0,1)

        _SelectedFillColor("Selected Fill Color", Color) = (1,0,0,0)
        _SelectedBorderColor("Selected Border Color", Color) = (1,0,0,0)
        _SelectedPulseSpeed("Selection Pulse Speed", Range(1.0, 10)) = 3.75

        _PathHiliteFillColorScale("Path Hilite Color Scale", Range(0.5, 1)) = 0.9
        _PathHiliteBorderColor("Path Hilite Border Color", Color) = (0,0,0,1)

        _MainTex("Texture", 2D) = "white" {}
        _BorderWidth("Border width", Range(0, 1)) = 0.1
    }

    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing
            #pragma shader_feature SELECTED
            #pragma shader_feature HILITED
            #pragma shader_feature PATH_HILITE

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float4 color : COLOR;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                fixed4 color : COLOR;
            };

            v2f vert (appdata v)
            {
                v2f o;

                o.vertex = UnityObjectToClipPos(v.vertex);
                o.color = fixed4(v.color.rgb, v.color.a);

                return o;
            }

            float4 _FillColor;
            float4 _BorderColor;

            float _HilitedFillColorScale;
            float4 _HilitedBorderColor;

            float4 _SelectedFillColor;
            float4 _SelectedBorderColor;
            float _SelectedPulseSpeed;

            float _PathHiliteFillColorScale;
            float4 _PathHiliteBorderColor;

            float _BorderWidth;

            fixed4 frag (v2f i) : Color
            {
                // Inside the if is the internal color
                if (i.color.g >= _BorderWidth)
                {
#if SELECTED
                    // When selected, we pulsate between the regular fill color and the selected fill color
                    float t = _Time[1] * _SelectedPulseSpeed;
                    float negOneToPosOne = sin(t);
                    float zeroToOne = (negOneToPosOne + 1.0f) / 2.0f;
                    float base = zeroToOne;
                    float extra = 1.0f - base;

                    return (base * _SelectedFillColor) + (extra * _FillColor);
#elif HILITED
                    float4 outColor = float4(_FillColor.rgb * _HilitedFillColorScale, _FillColor.a);
                    return outColor;

#elif PATH_HILITE
                    float4 outColor = float4(_FillColor.rgb * _PathHiliteFillColorScale, _FillColor.a);
                    return outColor;
#else
                    return _FillColor;
#endif
                }

                // Down here is for border
#if SELECTED
                return _SelectedBorderColor;
#elif HILITED
                return _HilitedBorderColor;
#elif PATH_HILITE
                return _PathHiliteBorderColor;
#else
                return _BorderColor;
#endif

                //float t = _Time[1] * 10.0f;
                //float negOneToPosOne = sin(t);
                //float zeroToOne = (negOneToPosOne + 1.0f) / 2.0f;
                //float base = zeroToOne;
                //float extra = 1.0f - base;

                //return (base * _BorderColor) + (extra * _HilitedBorderColor);
            }

            ENDCG
        }
    }
}

Thanks

John Lawrie

7564261–936154–VideoAndShader.zip (7.22 MB)

Your common denominator is Shader, look into shader settings/compatability or modification…

Thanks for your reply. Though I don’t exactly know what I should be looking for.

I opened up “Project Settings” | “Graphics” and I see there are some options related to shaders, but they appear to be related to built-in shaders. I don’t see a “Compatability” or “Modification” setting. This is what I see.

Is this where I should be looking?

(The pane to the right shows the options I see when I have my Territory material selected.)

Cheers,
John

What you’re seeing is called “aliasing”. You are defining your border with an “if” statement so it is an infinitely sharp edged line, and the value of the pixel depends entirely whether it is on one side or the other. If you have a very thin line some pixels miss it entirely giving you an irregular outline. Even when the line is quite thick the edges will be very jagged.

You need to add some transitional area to your border.

Something like this is probably enough, and lets you customize the transition width:

fixed4 frag (v2f i) : Color
{           
    fixed borderBlend = smoothstep(_BorderMin, _BorderMax, i.color.g);
    return lerp(_FillColor, _BorderColor, borderBlend);           
}

That gives you a constant border transition, and will probably be fine if you are typically viewing it a fixed distance. You could modify the edge smoothness dynamically using the screen-space partial derivative:

fixed4 frag (v2f i) : Color
    {
           
    fixed ddg = max(ddx(i.color.g), ddy(i.color.g)) * _BorderSmoothness;
           
    fixed borderBlend = smoothstep(_BorderMax - ddg, _BorderMax, i.color.g);
    return lerp(_FillColor, _BorderColor, borderBlend);        
}

That should work better if you are looking at it from different distances, but I wouldn’t use that unless you have issues with the simpler version. And this is all off the top of my head so no guarantees on typos, but that is the general idea.

As an aside, I removed most of the code in your shader because this is really all it should be doing.

Your pulse value doesn’t change per-pixel so doing it in the fragment shader is running millions of redundant calculations per frame. You should be calculating it once and baking it into your fill color. You also don’t want to use shader variants for something as trivial as selecting colors. Pre-calculate that and put the colors in a per-territory buffer that so you can more efficiently batch your scene. The SPR batcher will do this automatically with materials that share a shader variant so you can use one material per territory and the batcher will do it for you. You can do the same thing with the built-in pipeline but it will take more manual work. Reading the documentation on writing shaders compatible with the SPR batcher would be useful with this.

It wouldn’t really matter on PC, but on Quest you really need to minimize graphical state changes so you want to render your entire map in one draw call with a single shader variant.

Thanks for the info.

Just to make sure I understand, the border color and fill color would be computed in C# and passed into the shader. This would also include the calculation of the pulse color. Basically, the pulsing is just the fill color. So I would only need to compute it once per frame instead of once per pixel per frame.

Seems to make sense to me.