5
\$\begingroup\$

I'm experimenting in Unity with a tile-based game with crisp 32x32 tile textures and pixel perfect camera. I did not like the results coming from Unity's default Tilemap approach mainly because due to the crispiness of the textures, the edges between them (biome boundaries, if you will) were too sharp for my taste. I wanted to have a small margin between tiles where they interpolate smoothly.

As such, I eventually googled out that transitions and blending of textures can be done with shaders, a topic completely new to me, but I accepted the learning challenge. Thus, I present for review my first shader (of course after a few iterations and polish).

Sorry if the following introduction is overly lengthy or trivial but I felt a conceptual overview was necessary.

The challenge: blend the main texture with 4 adjacent ones within its margin (which is adjustable, but looks best to me at 2px for a 32x32 tile):

Tiles

We blend the textures in such a way that inside the tile and at the inner edge of the margin, the tile is rendered at 100% strength. Within the margin we then interpolate linearly into the adjacent texture up to the tile's edge where they mix at a 50/50 ratio (that tile will then blend from 50/50 to 100% of itself within its own margin).

In the corners, we blend all three contributing textures linearly, where the corner mixes at a 50/25/25 ratio (yes, it's a potential discontinuity but we never actually render that as a pixel). Here's a zoom to the top right corner with a couple points probed:

Interpolation explained

(Light grey points represent actual pixel probe points.)

Code incoming. The textures get assigned in my C# code to a temporary material, which passes them to the shader. Additionally, we need to handle literal edge cases: when the tile is the edge of the map, we turn off blending in that direction (also when adjacent cell has the same texture) and main texture applies 100% up to the edge. We do that using the _Blend* directives.

Shader "Custom/EdgeBlender"
{
    Properties
    {
        _BlendMargin ("Blend margin", float) = 0.0675 //2px for a 32x32 tile
    
        [PerRendererData]_MainTex ("Center texture", 2D) = "white" {}
        
        [PerRendererData]_BlendLeft ("Blend left texture", int) = 0
        [PerRendererData]_LeftTex ("Left texture", 2D) = "white" {}
        
        [PerRendererData]_BlendRight ("Blend right texture", int) = 0
        [PerRendererData]_RightTex ("Right texture", 2D) = "white" {}
        
        [PerRendererData]_BlendTop ("Blend top texture", int) = 0
        [PerRendererData]_TopTex ("Top texture", 2D) = "white" {}
        
        [PerRendererData]_BlendBottom ("Blend bottom texture", int) = 0
        [PerRendererData]_BottomTex ("Bottom texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM
        #pragma surface surf Standard
        #pragma target 3.5
        
        float _BlendMargin;

        sampler2D _MainTex;
        
        int _BlendLeft;
        sampler2D _LeftTex;
        
        int _BlendRight;
        sampler2D _RightTex;
        
        int _BlendTop;
        sampler2D _TopTex;
        
        int _BlendBottom;
        sampler2D _BottomTex;

        struct Input
        {
            float2 uv_MainTex;
            float2 uv_LeftTex;
            float2 uv_RightTex;
            float2 uv_TopTex;
            float2 uv_BottomTex;
        };
        
        // In a domain of [0; 1]:
        // Scale from -1 to 0 on interval [0; margin]
        // 0 on interval [margin; 1-margin]
        // Scale from 0 to 1 on inteval [1-margin; 1]
        float marginCalc(float position, float margin)
        {
            return sign(position - 0.5) * clamp(abs(position - 0.5) - (0.5 - margin), 0, margin) / margin;
        }

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            fixed4 main = tex2D(_MainTex, IN.uv_MainTex);
            float2 pos = IN.uv_MainTex.xy;
            
            fixed4 right = tex2D(_RightTex, IN.uv_RightTex);
            fixed4 left = tex2D(_LeftTex, IN.uv_LeftTex);
            fixed4 top = tex2D(_TopTex, IN.uv_TopTex);
            fixed4 bottom = tex2D(_BottomTex, IN.uv_BottomTex);
            
            // How much into the margins are we?
            // Absolute magnitude is from 0 (inside or margin begins) to 1 (edge of tile)
            // Sign signifies direction (postitive for right and top, negative for left and bottom)
            float marginX = marginCalc(pos.x, _BlendMargin);
            float marginY = marginCalc(pos.y, _BlendMargin);
            
            // Blend power tells us how much of foreign tiles will be mixed in
            // Goes from 0 inside and at inner margin edges, up to 0.5 on tile edges
            float blendPower = max(abs(marginX), abs(marginY)) / 2.0;
            
            // Which adjacent tiles will even play a role in the mix?
            bool leftContributes = (_BlendLeft == 1) && (marginX < 0);
            bool rightContributes = (_BlendRight == 1) && (marginX > 0);
            bool topContributes = (_BlendTop == 1) && (marginY > 0);
            bool bottomContributes = (_BlendBottom == 1) && (marginY < 0);
            
            // Mix ratio between two adjacent textures within the corner
            float cornerMixRatio = abs(marginY) / (abs(marginX) + abs(marginY));
            
            fixed4 result;
            
            if (leftContributes && topContributes) {
                fixed4 mixin = lerp(
                    left,
                    top,
                    cornerMixRatio);
                result = lerp(main, mixin, blendPower);
            } else if (leftContributes && bottomContributes) {
                fixed4 mixin = lerp(
                    left,
                    bottom,
                    cornerMixRatio);
                result = lerp(main, mixin, blendPower);
            } else if (rightContributes && topContributes) {
                fixed4 mixin = lerp(
                    right,
                    top,
                    cornerMixRatio);
                result = lerp(main, mixin, blendPower);
            } else if (rightContributes && bottomContributes) {
                fixed4 mixin = lerp(
                    right,
                    bottom,
                    cornerMixRatio);
                result = lerp(main, mixin, blendPower);
            } else if (leftContributes) {
                result = lerp(main, left, blendPower);
            } else if (rightContributes) {
                result = lerp(main, right, blendPower);
            } else if (topContributes) {
                result = lerp(main, top, blendPower);
            } else if (bottomContributes) {
                result = lerp(main, bottom, blendPower);
            } else {
                result = main;
            }
            
            o.Albedo = result;
        }
        ENDCG
    }
}

In terms of code correctness, I'm fairly sure it works well, at least I'm satisfied with the result and pretty proud of it:

End result

Code review objectives:

  • What I'm looking for here is a general review of my shader code - any no-no's with regard to writing shaders, any features of the language (like functions I'm not aware of) that could simplify it, wrong types used, etc.

  • I put some effort to express marginCalc function without if statements. It does its job but is quite ugly. Does branchless programming even matter for GPU programming as much as it does for CPU?

  • I'm not thrilled with the "if-ology" in the last part of the code but couldn't figure out a better way.

  • The optimization of not blending if adjacent texture is same as current was done with performance in mind (we skip some lerp-ing if set to 0) but, being inexperienced with shaders, I don't even know if that matters in this case?

  • Eventually, I ran into the issue of "Too many texture interpolators would be used for ForwardBase pass (11 out of max 10)" and had to upgrade #pragma target to 3.5. My understanding is, this reduces hardware compatibility of my shader. If I went all silly and interpolated also diagonally (almost doubling the number of textures in use), I'd surely run into this problem again, and have to "upgrade" further. This got me thinking - maybe using the shader was not the best idea, after all?

Addenum: In case it's relevant to understanding the logic, marginCalc basically recalculates the position in one dimension [0; 1] into a position with regard to the margin (0 - solid within the tile, 1/-1 - on the edge of tile):

marginCalc chart

\$\endgroup\$
1
  • \$\begingroup\$ I'm unsure what the 'Default Maps' should be assigned to? Since this is different for each tile. I've tried different things like a flat black or flat white image. As well as trying to put the texture of 1 tile in there. But for some reason all of the tiles are sort off greyed out, it looks as if there would be some sort of fog in the scene. \$\endgroup\$
    – PeakAlive
    Commented Jan 28, 2022 at 9:23

1 Answer 1

5
\$\begingroup\$

(Self-answer, and not comprehensive by any measure)

After some consideration and more experimenting, I can offer one insight to this approach. It does not relate to shader code in any way but rather the structural approach.

The shader is too "fat" and deals with too many textures/does too many things.

Since every tile is rendered by a prefab, managed by a hand-written grid manager (remember, we're not using Unity's Tilemap here), we can make it more complex than a single quad with a single material and shader.

Solution: "9-slice" the tile into 9 areas

It's a solution inspired by the idea of 9-splicing sprites to reuse them more effectively and avoiding texture scaling issues.

9-spliced tile

The tile in question (highlighted in yellow) is split into 9 areas (each of them is a separate quad with its own material-shader):

  • Central, deals with only one texture, trivial shader.
  • 4 Edge areas, deal with only two textures, very simple lerp-ing shader, easily reusable by clever rotations.
  • 4 Corner areas, deals with only 3-4 textures (depending on whether you blend diagonally as well), somewhat more complex texture matth, again, reusable with clever rotations.

A significant improvement over a single shader, with massive bloated logic and dealing with 5 or 9 textures.

A snippet from the edge shader:

        void surf(Input IN, inout SurfaceOutput o)
        {
            float3 pos = IN.worldPos - _RefZeroZero;
            
            fixed4 main = tex2D(_MainTex, pos);
            fixed4 blend = tex2D(_BlendTex, pos);

            float blendPower = 0.0;

            // How far from the center are we?
            float magnitude = dot(pos - float4(0.5, 0.5, 0, 0), _BlendDirection);

            // Are we into the margin?
            if (magnitude > 0.5 - _BlendMargin)
            {
                // Linearly scale from 0 at the margin's start to 0.5 at tile's edge
                blendPower = (magnitude - (0.5 - _BlendMargin)) / _BlendMargin / 2.0;
            }
            
            o.Albedo = lerp(main, blend, blendPower);
        }

Additional parameters included:

  • _RefZeroZero - The world position of the (0, 0) of the tile (bottom left corner) for easier texture calculations.
  • _BlendDirection - Unit vector pointing from the center of this tile towards the one we're blending with. Simplifies math a lot thanks to dot product (this is how I reuse the shader for all 4 edge areas - by just varying this vector).

Sneak peek into the final result:

Final result

And with very wide margins (0.25 of tile) for comparison (although I'd never go this high in an actual game):

Wide margins

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.