Visibles Seams on textures tiles

Hello,

I work on a shader that use 4 textures, provided by a c# so thoses textures might change over time.
I’m getting visible seams between thoses textures.
After a brief search, I found a solution with derivatives, but it’s not fixing the problem, juste enhancing the result a little bit.

Shader "Custom/TileShader"
{
    Properties
    {
       [NoScaleOffset] _TileBottomLeft ("Bottom Left Tile", 2D) = "white" {}
       [NoScaleOffset] _TileBottomRight ("Bottom Right Tile", 2D) = "white" {}
       [NoScaleOffset] _TileTopLeft ("Top Left Tile", 2D) = "white" {}
       [NoScaleOffset] _TileTopRight ("Top Right Tile", 2D) = "white" {}
       _GlobalCenterU ("Global U", Range(0.0, 1.0)) = 0.5
       _GlobalCenterV ("Global V", Range(0.0, 1.0)) = 0.5
       [Toggle] _DebugMode ("Debug Mode", Float) = 0
       [Toggle] _SeamsFix ("Seams Fix", Float) = 0
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            sampler2D _TileBottomLeft;
            sampler2D _TileBottomRight;
            sampler2D _TileTopLeft;
            sampler2D _TileTopRight;
            float _GlobalCenterU;
            float _GlobalCenterV;
            float _DebugMode;
            float _SeamsFix;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            

            float4 frag (v2f i) : SV_Target
            {
                float2 uv = i.uv;
                float2 C = float2(_GlobalCenterU,_GlobalCenterV);

                // From local UV to Quad (2x2) UV
                float2 UVquad = (C-float2(0.25,0.25)) + uv*0.5; // pour un quad entre 0 et 1
                float2 UVquad2 = 2*UVquad;

 
                float2 fuv = frac(UVquad2);

                // Sample the correct texture based on the tile
                float4 color;
                if (UVquad2.x < 1 && UVquad2.y < 1) // Bottom-left tile
                {
                    color = tex2D(_TileBottomLeft, fuv);
                }
                else if (UVquad2.x >= 1 && UVquad2.y < 1) // Bottom-right tile
                {
                    color = tex2D(_TileBottomRight, fuv);
                }
                else if (UVquad2.x < 1 && UVquad2.y >= 1) // Top-left tile
                {
                    color = tex2D(_TileTopLeft, fuv);
                }
                else // Top-right tile
                {
                    color = tex2D(_TileTopRight, fuv);
                }
                if(UVquad2.x<0 || UVquad2.x>2 ||UVquad2.y<0 || UVquad2.y>2)
                {
                color = float4(0,0,0,0);
                }

                if (_SeamsFix > 0.5)
                {
                    float2 ddxUV = ddx(UVquad2);
                    float2 ddyUV = ddy(UVquad2);

                    if (UVquad2.x < 1 && UVquad2.y < 1) // Bottom-left tile
                    {
                        color = tex2Dgrad(_TileBottomLeft, fuv, ddxUV, ddyUV);
                    }
                    else if (UVquad2.x >= 1 && UVquad2.y < 1) // Bottom-right tile
                    {
                        color = tex2Dgrad(_TileBottomRight, fuv, ddxUV, ddyUV);
                    }
                    else if (UVquad2.x < 1 && UVquad2.y >= 1) // Top-left tile
                    {
                        color = tex2Dgrad(_TileTopLeft, fuv, ddxUV, ddyUV);
                    }
                    else // Top-right tile
                    {
                        color = tex2Dgrad(_TileTopRight, fuv, ddxUV, ddyUV);
                    }
                }



                 if (_DebugMode>0.5)
                {
                       float2 uv2 = 2.0*uv;
                       float2 fuv = float2(frac(uv2.x),frac(uv2.y));
                       if (uv2.x<=1 && uv2.y<=1)       color = tex2D(_TileBottomLeft, fuv);
                       if (uv2.x>1 && uv2.y<=1)       color = tex2D(_TileBottomRight, fuv);
                       if (uv2.x<=1 && uv2.y>1)       color = tex2D(_TileTopLeft, fuv);
                       if (uv2.x>1 && uv2.y>1)       color = tex2D(_TileTopRight, fuv);
                }

                return color;
            }
            ENDCG
        }
    }
}

Here is the result without the fix/with the ddx semi fix:


For the texture sampler, I have tried everything. Currently warp mode is clamp, mipmaps are enable.

This seems a basic problem but can’t find any solution.
Thanks !

Ben

PS: I know the shader is not optimised, that was not the point at this time.

There are two issues that I can see could be causing the problems you’re seeing. One is indeed related to derivatives, though you could fix that issue by removing the frac() line above and offsetting the UVs within each conditional statement.

                float2 fuv = frac(UVquad2); // don't need this line

                // Sample the correct texture based on the tile
                float4 color;
                if (UVquad2.x < 1 && UVquad2.y < 1) // Bottom-left tile
                {
                    color = tex2D(_TileBottomLeft, UVquad2); // no offset
                }
                else if (UVquad2.x >= 1 && UVquad2.y < 1) // Bottom-right tile
                {
                    color = tex2D(_TileBottomRight, UVquad2 - float2(1,0)); // offset UV
                }
                else if (UVquad2.x < 1 && UVquad2.y >= 1) // Top-left tile
                {
                    color = tex2D(_TileTopLeft, UVquad2 - float2(0,1)); // offset UV
                }
                else // Top-right tile
                {
                    color = tex2D(_TileTopRight, UVquad2 - float2(1,1)); // offset UV
                }
                if(UVquad2.x<0 || UVquad2.x>2 ||UVquad2.y<0 || UVquad2.y>2)
                {
                color = float4(0,0,0,0);
                }

This avoids needing to use explicit derivatives, because the UV being used by each texture sampler is continuous, where as the frac() causes the discontinuity that creates the original ugly edge.

The remaining issue could be caused by bilinear filtering itself. Each tile texture only knows about itself and not the textures nearby. So when you get to another “edge” from the tile blinear filtering with the pixels on the opposite side of the texture. Though you said you’re using wrap mode Clamp, which should avoid this. So if you’re still seeing it, I suspect the textures you’re creating from c# aren’t clamping properly. When loading texture assets from an external source at runtime you have to be careful when you set things like the wrap mode as if you do it before you load the image data, Unity will sometimes blow those settings away. So that’s my actual guess as to what you’re seeing.

It should be noted that once that’s fixed, you’ll still possibly see a seam, but it will be even less pronounced. That’s because the edge between the two textures won’t have any anti-aliasing like you would get between pixels within a single texture. You can fix that by not using an else to pick between the four tiles and fade them out across a texel width, or at least one screen pixel, and adding them to the color.

Shader "Custom/TileShader"
{
    Properties
    {
       [NoScaleOffset] _TileBottomLeft ("Bottom Left Tile", 2D) = "white" {}
       [NoScaleOffset] _TileBottomRight ("Bottom Right Tile", 2D) = "white" {}
       [NoScaleOffset] _TileTopLeft ("Top Left Tile", 2D) = "white" {}
       [NoScaleOffset] _TileTopRight ("Top Right Tile", 2D) = "white" {}
       _GlobalCenterU ("Global U", Range(0.0, 1.0)) = 0.5
       _GlobalCenterV ("Global V", Range(0.0, 1.0)) = 0.5
       [Toggle] _DebugMode ("Debug Mode", Float) = 0
       [Toggle] _SeamsFix ("Seams Fix", Float) = 0
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            sampler2D _TileBottomLeft;
            float4 _TileBottomLeft_TexelSize; // assumes all tiles are the same size
            sampler2D _TileBottomRight;
            sampler2D _TileTopLeft;
            sampler2D _TileTopRight;
            float _GlobalCenterU;
            float _GlobalCenterV;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 pos : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            float TileMask(float2 uv, float2 texelSize)
            {
                uv = uv * 2.0 - 1.0;
                uv = 1.0 - abs(uv);
                uv = saturate(uv / (texelSize * 2.0) + 0.5);
                return uv.x * uv.y;
            }

            float4 frag (v2f i) : SV_Target
            {
                float2 uv = i.uv;
                float2 C = float2(_GlobalCenterU,_GlobalCenterV);

                float2 UVquad = (C-float2(0.25,0.25)) + uv*0.5; // pour un quad entre 0 et 1
                float2 UVquad2 = 2.0*UVquad;

                // find out how much the UV is changing within each pixel and i
                float2 derivXUVWidth = ddx(UVquad2);
                float2 derivYUVWidth = ddy(UVquad2);
                float2 derivXYUVWidth = float2(
                    length(float2(derivXUVWidth.x, derivYUVWidth.x)),
                    length(float2(derivXUVWidth.y, derivYUVWidth.y))
                    );

                float2 texelWidth = _TileBottomLeft_TexelSize.xy;
                texelWidth = float2(
                    max(derivXYUVWidth.x, texelWidth.x),
                    max(derivXYUVWidth.y, texelWidth.y)
                    );
                float2 halfTexelSize = texelWidth.xy * 0.5;

                // Sample the correct texture based on the tile
                float4 color = 0.0;

                // each 
                if (UVquad2.x < (1 + halfTexelSize.x) && UVquad2.y < (1 + halfTexelSize.y) ) // Bottom-left tile
                {
                    float2 tileUV = UVquad2;
                    float mask = TileMask(tileUV, texelWidth);
                    color += tex2D(_TileBottomLeft, tileUV) * mask;
                }
                if (UVquad2.x >= (1 - halfTexelSize.x) && UVquad2.y < (1 + halfTexelSize.y)) // Bottom-right tile
                {
                    float2 tileUV = UVquad2 - float2(1.0,0.0);
                    float mask = TileMask(tileUV, texelWidth);
                    color += tex2D(_TileBottomRight, tileUV) * mask;
                }
                if (UVquad2.x < (1 + halfTexelSize.x) && UVquad2.y >= (1 - halfTexelSize.y)) // Top-left tile
                {
                    float2 tileUV = UVquad2 - float2(0.0,1.0);
                    float mask = TileMask(tileUV, texelWidth);
                    color += tex2D(_TileTopLeft, tileUV) * mask;
                }
                if (UVquad2.x >= (1 - halfTexelSize.x) && UVquad2.y >= (1 - halfTexelSize.y)) // Top-right tile
                {
                    float2 tileUV = UVquad2 - float2(1.0,1.0);
                    float mask = TileMask(tileUV, texelWidth);
                    color += tex2D(_TileTopRight, tileUV) * mask;
                }

                return color;
            }
            ENDCG
        }
    }
}

Without 1 texel seam blending:

With 1 texel seam blending:

1 Like