Hexagonal tilemap with blending

this shader draws an hexagonal tilemap, blending neighboring tiles together.

– For a simpler version without blending, start here: https://godotshaders.com/shader/hexagonal-tilemap-simple-version/ 

– note that it comes with no garantees on performances 🙂  If you have advice on how to improve it / to get nicer blending, post your comments!

 

Inputs:

– an array of textures, one for each “biome”. Textures should be seamless to avoid visual artifacts, and have the “repeat” flag (Flag = 7 is the most likely choice here)

– a texture containing the “biome” of each tile, encoded on the red chanel. This texture should have Flag 0  (No mipmaps, no filter, no repeat. ) .  Optionally, the green chanel of this texture contains data to decide if an edge between two tiles should be “smooth” or “sharp”. (tiles with the same value on green chanel will get a smooth transition)

– a seamless noise texture used to get less regular transitions at each tile. I use a simple 64×64 pregenerated image for that.

 

More explanations on how it works on my blog : https://www.gobsandgods.com/blog/hexagonal-shader.html

 

See also:

– Hexagonal primitives where adapted from : https://www.redblobgames.com/grids/hexagons/

– this shader is inspired from : https://godotshaders.com/shader/rimworld-style-tilemap-shader-with-tutorial-video/   ( Main difference: shader in this link is using a rectangular grid, I basically tried to adapt it to the hexa case. )

 

CC0, but feel free to credit me!

Shader code
shader_type canvas_item;

///  ****  DATA to pass to this shader  **** ///

// array containing the textures to paint the different types of tiles (eg plain, water,...)
// These textures must have the REPEAT Flag. (Mimpmaps and filter are also good ideas) 
// They should be seamless to avoid visual artifacts.
uniform sampler2DArray textureAtlas;

// this texture is used as a 2D array containing the tile type for each tile.
// tile coordinates are encoded using "offseted" coordinates, ie in the same way as in Godot.Tilemap 
// texture size is thus equal to the world grid size.
// It * must *  have flag 0 (No mimmaps, No filter, No repeat.) 
uniform sampler2D mapData;

// a seamless texture containing random noise on each chanel. Mine is 64x64. 
// It is used to smooth the transitions between tiles.
uniform sampler2D noiseTexture;

// size of the textures in the atlas
uniform float atlasSizeInPixels = 512;
uniform float zoomOnAtlas = 0.3;

// size of an hexa in pixels
uniform float CellSize = 32;
// size of the hexa grid, +1  (eg (101,101) for a 100x100 grid) 
uniform vec2 GridSizePlusOne = vec2(101,101);
// size of the world in pixels: set to CellSize * (1.5f , Sqrt(3)) * GridSize
uniform vec2 WorldPixelSize; 

///  *** Metaparameters  **** ///
// This parameters control how the border between tiles is drawn
// try various values to see what works!

// Around a 3-tiles junction where two tiles share the same texture, we would mostly display this common texture and not the third one. Else we get a "hole" on the border, and the grid stay too visible.
// This parameter ensures that. Set to 0 to desactivate (and the hexa grid will become more apparent) 
uniform float similartilesboost = 1.;

// parameters of the noise controlling the textures boundaries
uniform float barynoiselevel = 1.2 ;
uniform float barynoisescale = 0.3 ;

// parameters of the noise which moves the grid vertices.
uniform float xynoisescale = 0.11;
uniform float xynoiselevel = 0.33;

// control how smooth / sharp should be the transition between similar textures.
// 0 -> ultra smooth   1 -> sharp transition 
uniform	float minSharpness = 0.3;

// for hexa geometry
const float sqrt3 = 1.73205080757;

// moving to neigboors in the hexa grid in axial coordinates. See also: https://www.redblobgames.com/grids/hexagons
const ivec2 dirs[6] = ivec2[](
	ivec2(0,1), // S
	ivec2(0,-1),// N
	ivec2(1,0), // SE
	ivec2(-1,0), //NW
	ivec2(1,-1), // NE
	ivec2(-1,1)	 //SW
);

//// **** Hexa grid primitives.  **** ////
// these functions were directly adapted from:  https://www.redblobgames.com/grids/hexagons

// Find to which hexagon belongs a points.
// input :  "axial coordinates" of a point
// output: axial coordinates of the hexa 
ivec2 axial_round_(vec2 frac)
{
	float s0 = -frac.x - frac.y;
    float q = round(frac.x);
    float r = round(frac.y);
    float s = round(s0);

    float q_diff = abs(q - frac.x);
    float r_diff = abs(r - frac.y);
    float s_diff = abs(s - s0);

    if (q_diff > r_diff && q_diff > s_diff)
	{
        q = -r-s;		
	}
    else if (r_diff > s_diff)
	{
        r = -q-s;
	}
    return ivec2( int( q  ), int( r ));
}

// Find to which hexagon belongs a points.
// input :  "pixel coordinates" of a point
// output: axial coordinates of the hexa 
ivec2 GetTileInAxialCoo(vec2 xy)
{
	float x = xy.x;
    float q = ( 2./3.0 * x  );
    float r = (-1./3.0 * x  +  sqrt3/3.0 * xy.y);
    return axial_round_(vec2(q, r));
}

// Computes the position in pixel coo of an hexa
// input: hexa in axial coordinates
// output: position of the pixel (in the normal 'othonormal' coordinates) 
vec2 TileAxialCooCenter( ivec2 hex )
{
	float x = float(hex.x);
	return vec2( x * 3.0 / 2.0, (x / 2.0 + float(hex.y)) * sqrt3);	
}

// Convert axial coo on an hexa to "offset coordinates" we used to store data
ivec2 AxialToTile(ivec2 hex) // "axial_to_oddq" on redblobgames
{
    int col = hex.x;
    int row = hex.y + ((hex.x  + (hex.x %2 ) ) / 2 );    
    return ivec2(col, row);	
}

// Compute "offset coordinates" of the hexa containing input point.
ivec2 GetTile( vec2 xy )
{
	return AxialToTile( GetTileInAxialCoo(xy));
}


//// ***  Reading texture data for one hexagon  *** ////
// for each hexagon, the texture id is stored in mapdata, in the red chanel.
// green chanel also contains the 'tyle tipe' (to give a smooth transition to tiles of the same type, and a sharp transition between different types).

// Fetch Texture Id from hexa coordinates (in "offset coordinates") 
// texture id is stored in the RED chanel of the mapData texture
int getTileId(ivec2 tile) 
{
	vec4 color = texture(mapData,  vec2(tile) / GridSizePlusOne, 0);
	return int(color.r * 255.);
}

// Fetch Texture Id  and tile type from hexa coordinates 
ivec2 getTileIdAndType(ivec2 tile) 
{
	vec4 color = texture(mapData,  vec2(tile) / GridSizePlusOne, 0);
	return ivec2(int(color.r * 255.) , int(color.g * 255.) ) ;
}

// Fetch color from at point xy for specified texture id
// input:  xy :  coordinates of the pixel in the world
// tileId : id of the texture to read
vec4 getColorForCurrentPixel(vec2 xy, int tileId) 
{
	vec2 atlastxy =  xy / zoomOnAtlas / atlasSizeInPixels ;
	return texture( textureAtlas,  vec3( atlastxy.x , atlastxy.y , float(tileId) )  );
}

// ** Smoothing helpers  ** //

// euclidian distance ( todo: check if there is a faster built-in function for this ?)
float d2( vec2 a, vec2 b )
{
	return (a.x - b.x) *(a.x - b.x) +
	       (a.y - b.y) *(a.y - b.y) ;
}

float det2( vec2 a , vec2 b )
{
	return a.x * b.y - a.y * b.x;
}

// Barycentric coordinates of point 'position' in triangle with vertices v1, v2, v3
vec3 BarycentricCoefs( vec2 v1,vec2 v2,vec2 v3, vec2 xy)
{
 	vec2 px = xy - v1;
    vec2 py = xy - v2;
    vec2 pz = xy - v3;
    float alpha = det2(py, pz);
    float beta = det2(pz, px);
    float gamma = det2(px, py);
	float sum = alpha + beta + gamma;
    return vec3 ( alpha / sum , beta / sum, gamma / sum );
}


// Compute weights for blending the textures of the 3 nearest hexagons
// barycentric:  Barycentric coordinates of current pixels, in the triangle made by the 3 nearest hexa centers
// tile1, tile2 and tile3: textureid and tile type of the three surrounding hexagons
// output 
vec3 GetColorWeights_(vec3 barycentric, 
                      ivec2 tile1, 
					  ivec2 tile2, 
					  ivec2 tile3)
{
	float w1 = barycentric.x;
	float w2 = barycentric.y;
	float w3 = barycentric.z;

	// In a triangle made by two hexas with the same texture, and a third one with a different texture, we want to make sure that the middle of the trinagle is from the dominant texture.
	// To do this, we increase the weigh of an hexa sharing a texture with a neigboor	
	if( tile1.x == tile2.x ) 
	{
		if( w1 > w2 )
			w1 += similartilesboost * w2;
		else
			w2 += similartilesboost * w1;
	}
	if( tile1.x == tile3.x ) 
	{
		if( w1 > w3 )
			w1 += similartilesboost* w3;
		else
			w3 += similartilesboost* w1;
	}
	if( tile2.x == tile3.x ) 
	{
		if( w2 > w3 )
			w2 += similartilesboost* w3;
		else
			w3 += similartilesboost* w2;
	}

    // assign a 'minSharpness' value to each edge of the triangle.
	// We use sharp edges when the vertices have different types, and smooth edges otherwise
 	float sharpness12 = min(minSharpness+ float(abs(tile1.y -tile2.y))  ,1. );
	float sharpness13 = min(minSharpness+ float(abs(tile1.y -tile3.y))  ,1. );
	float sharpness23 = min(minSharpness+ float(abs(tile2.y -tile3.y))  ,1. );

    // Mix the sharpness of each edges to get sharpness at current point
	float sharpness = (w1*w2) * sharpness12 + 
               	 (w1*w3) * sharpness13 + 
			     (w2*w3) * sharpness23 ;
	// normalising by weighs sum
	sharpness = sharpness / ( w1 * w2 + w2 * w3 + w3 * w1 + 0.01 ); 

    // To get sharp transitions, we set weights to 0 except on the highest one
	float maxw = max(max(w1,w2),w3); 
	w1 *= mix( 1, smoothstep( maxw -0.1, maxw, w1 ) , sharpness) ;
	w2 *= mix(1, smoothstep( maxw -0.1, maxw, w2 )  , sharpness) ;
	w3 *= mix(1, smoothstep( maxw -0.1, maxw, w3 )  , sharpness) ;

    // normalise the weights	
    float sum = w1 + w2 + w3;
	return vec3( w1 / sum, w2 / sum, w3 / sum  );
}


float GetNoiseChanelAt( vec4 noiseColor, ivec2 hexa_axialcoo  )
{
    // choice of the chanel depending on the hexa, using a 3-coloring of the hexagonal grid
	int hexaColor = (hexa_axialcoo.x - hexa_axialcoo.y) % 3;

    // switching chanel depending on "hexaColor" 
    return (hexaColor == 0)? noiseColor.g :
   	      ((hexaColor == 1)? noiseColor.b :
		                     noiseColor.a );
}

/// putting everything together
void fragment() 
{
	// pixel position scaled to hexagon size 
	// the (3/4,0) offset is there to perfectly overlap my centered tilemap 
	vec2 xy = UV * WorldPixelSize / CellSize + vec2(3.0/4.0,0);

    // pixel position in the textures (keeping 1 screen pixel <-> 1 texture pixel )
	vec2 xytexture = UV * WorldPixelSize;	

    // Using noise to move the position of current point in  hexa grid
	vec4 noiseColor = texture( noiseTexture, xy * xynoisescale  );
	vec2 noise_xy = vec2(noiseColor.r - 0.5f , noiseColor.g - 0.5f) ;	
	 xy += noise_xy * xynoiselevel;

	// Retrieve hexa containing current pixel
	ivec2 hexa1 = GetTileInAxialCoo(xy); 

   // Find the two next nearest neigbooring hexagons.
   // Current point should be in the triangle defined by the centers of these two hexagons and the center of current hexagon.
   float dist2 = 100.;
   float dist3 = 100.;

   ivec2 hexa2; 
   ivec2 hexa3;

    // iterating on each neigboor of current hexagon ...
    /// ... with a for loop and some 'if'. TODO: check if  more shader friendly implementations actually makes a difference.
	for (int i = 0; i < 6; i++)
	{
		ivec2 currentHexa = hexa1 + dirs[i];
		vec2 tileCenter = TileAxialCooCenter(currentHexa); 
		float currentDistance = d2(xy , tileCenter); 
	    if( currentDistance < dist2 )
		{
			hexa3 = hexa2;
			hexa2 = currentHexa;
			dist3 = dist2;
			dist2 = currentDistance;
		}
		else if(currentDistance  <  dist3 )
		{
			hexa3 = currentHexa;			
			dist3 = currentDistance;
		}
	}

    // Current point is in the triangle made by the three hexagons we found (main + 2 nearest neigboors)
    // compute current point barycentric coordinates in this triangle 	
	vec2 center1 = TileAxialCooCenter(hexa1);
	vec2 center2 = TileAxialCooCenter(hexa2);
	vec2 center3 = TileAxialCooCenter(hexa3);	
    vec3 barycentric = BarycentricCoefs(center1 , center2, center3, xy);
		
	// Noising the barycentric coordinates.
    vec4 noisecolor = texture(noiseTexture, xy * barynoisescale );

	float noise1 =  GetNoiseChanelAt(noisecolor , hexa1 )* barynoiselevel + 1.;
	float noise2 =  GetNoiseChanelAt(noisecolor , hexa2 )* barynoiselevel + 1.;
	float noise3 =  GetNoiseChanelAt(noisecolor , hexa3 )* barynoiselevel + 1.;
	
    // Using multiplicative noise to preserve the fact that weight of a vertices is 0 on the opposite edge... This is important because the third vertices change when we cross the edge.
	barycentric *= vec3( noise1 , noise2, noise3 );

	// query texure and tile data on these 3 hexas:	
	ivec2 tile1 = getTileIdAndType(AxialToTile(hexa1));	
	ivec2 tile2 = getTileIdAndType(AxialToTile(hexa2));		
	ivec2 tile3 = getTileIdAndType(AxialToTile(hexa3));

    // Compute color weight of each texture
	vec3 weights = GetColorWeights_( barycentric , tile1, tile2 , tile3 );		

    // read texture of each 3 hexas at current point
    vec4 color1 = getColorForCurrentPixel( xytexture , tile1.x );
    vec4 color2 = getColorForCurrentPixel( xytexture , tile2.x );
    vec4 color3 = getColorForCurrentPixel( xytexture , tile3.x );

    // blend these colors according to weighting
	COLOR = color1 * weights.x 
	      +  color2 * weights.y 
	      +  color3 * weights.z ;
	
}
Tags
2d, hexagons, tilemap
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

More from alexandre.gilotte

Hexagonal tilemap, simple version

Related shaders

Hexagonal tilemap, simple version

2D tilemap tile blending (texture splatting)

Hexagonal tiling + cog wheels

Subscribe
Notify of
guest

1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Beider
8 months ago

Really neat shader, glad to see my shader was useful. I find the hex version interesting, I think you could probably generate a blend texture for a hex map as well just need some more maths.

Your end result looks great though.