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):
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:
(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:
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):