Mosaics have their roots in ancient culture. Floor mosaics comprised of tiny squares of stone and glass were commonly used as decorative art in the ancient world and are still used as a form of artistic expression in modern times. Perhaps you could even argue that pixel art is a type of digital mosaic born out of technical limitations! Thatâs not as silly a suggestion as youâd think - and weâre going to prove it today by turning arbitrary images into mosaics by using pixelation together with a tiled texture overlay.
Little squares
Contemporary display technology already displays your image as a series of tiny squares - pixels. This would be a boring tutorial if we left it there, so weâre going to let the user specify how many tiles should be visible on the screen and adjust the colour of the pixels within those tiles accordingly. There are several ways to do that - a shader could aggregate the colours within those tiles - but by far the easiest way to do this is outside of shaders: weâll use scripting to decrease the resolution of the texture while leaving it blocky.
However, mosaics arenât just made up of the tiles - thereâs space in between for whatever binding material holds those tiles together. For that, weâll overlay a texture on top of the pixelated image, tiled such that the pixelated image lines up with the overlay.
Please download the project repository from GitHub if youâd like to follow along!
Pixelation
The first step is to reduce the resolution of the image. As weâve discussed, we could do it within a shader - but itâs far easier to do this outside of the shader. To start off, weâll look at MosaicEffect.cs
, found at Scripts/Image Effects/MosaicEffect.cs
. Letâs introduce a member variable to start off with, the one that controls how many tiles to use: xTileCount
.
[SerializeField]
private int xTileCount = 100;
We only need to know the number of tiles in the x-direction, since we can calculate the number in the y-direction using the aspect ratio of the screen. Weâll take a closer look at the OnCreate
function soon enough, but first we shall look at Render
.
public override void Render(RenderTexture src, RenderTexture dst)
{
RenderTexture tmp =
RenderTexture.GetTemporary(xTileCount,
Mathf.RoundToInt(((float)src.height / src.width) * xTileCount));
...
}
Using xTileCount
, we begin by calculating the size of the image after its resolution is decreased. Itâs easy - we multiply the number of tiles in the x-direction by the aspect ratio of the texture. Thereâs two ways to access the dimensions of the screen here; weâll use the dimensions of the image texture with src.width
and src.height
, and weâll see the alternative a little later. Those new image dimensions are used to create a temporary RenderTexture
which will act as an intermediate - its only purpose is to resize the image.
We then change the FilterMode
of the temporary texture to Point
- the default is FilterMode.Bilinear
.
...
tmp.filterMode = FilterMode.Point;
...
Weâre using Graphics.Blit
to transfer image data from the src
texture to tmp
, which has a lower resolution, meaning that some image data is lost. Unity needs to average out a handful of pixels in src
to determine the colour of each pixel in tmp
- it works as youâd expect. However, when resizing back up from tmp
to dst
, which has the same dimensions as src
, the default behaviour with FilterMode.Bilinear
is to interpolate between tmp
pixels to obtain pixel colours for dst
. By changing the filter mode to Point
, it wonât perform interpolation and weâll get a blocky output texture.
Graphics.Blit(src, tmp);
Graphics.Blit(tmp, dst, baseMaterial);
Thatâs it for Render
. Now, letâs assume baseMaterial
doesnât modify anything and youâll see screen output like this:
Mosaic Tiles
Now we can get on with writing a shader! This one will overlay a grid texture on top of the image texture to simulate the gaps between tiles where you would see some sort of binding material such as cement. Letâs run over the properties weâll include. The shader file can be found at Resources/Shaders/Mosaic.shader
.
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_OverlayTex("Overlay Texture", 2D) = "white" {}
_OverlayColour("Overlay Colour", Color) = (1, 1, 1, 1)
_XTileCount("X-axis Tile Count", Int) = 100
_YTileCount("Y-axis Tile Count", Int) = 100
}
From top to bottom, we start with _MainTex
, our image texture, as standard. _OverlayTex
is a small tileable texture that weâll place over every tile - _OverlayColour
will let us add a colour tint to the overlay. Then, _XTileCount
and _YTileCount
will be the number of tiles in the x- and y-direction respectively. Weâll include those just above the fragment shader like this:
uniform sampler2D _MainTex;
uniform sampler2D _OverlayTex;
uniform float4 _OverlayColour;
uniform int _XTileCount;
uniform int _YTileCount;
The fragment shader itself is simple. Weâll start by sampling the image texture as usual.
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
...
return col;
}
After sampling the image texture, weâll need to figure out the UVs for sampling the overlay texture. We passed in _XTileCount
and _YTileCount
as parameters for this purpose.
float2 overlayUV = i.uv * float2(_XTileCount, _YTileCount);
Determining the colour of the overlay is easy then - just sample the texture and multiply by _OverlayColour
, which we also passed in by parameter.
float4 overlayCol = tex2D(_OverlayTex, overlayUV) * _OverlayColour;
Finally, all we must do is combine the two. Itâs as simple as using the lerp function using the overlay textureâs alpha channel as the proportion parameter; the overlay texture is mostly empty space with an alpha of zero, so those areas wonât have any overlay.
col = lerp(col, overlayCol, overlayCol.a);
Our shader is now complete! But weâre going to have to return to the script because we havenât finished hooking up all our script variables to the shader. With an xTileCount
of 75 set on our MosaicEffect
asset, the overlay will still tile using the default values of 100 in both directions.
Putting things in place
Weâll include a couple more member variables and make them accessible to the Inspector. We need to pass the overlay texture and colour to the shader.
[SerializeField]
private Texture2D overlayTexture;
[SerializeField]
private Color overlayColour = Color.white;
And now weâll loop back right to the start of the article and look at the OnCreate
function.
// Find the Mosaic shader source.
public override void OnCreate()
{
baseMaterial = new Material(Resources.Load<Shader>("Shaders/Mosaic"));
baseMaterial.SetTexture("_OverlayTex", overlayTexture);
baseMaterial.SetColor("_OverlayColour", overlayColour);
baseMaterial.SetInt("_XTileCount", xTileCount);
baseMaterial.SetInt("_YTileCount",
Mathf.RoundToInt((float)Screen.height / Screen.width * xTileCount));
}
It takes the same format as the other XYZEffect.cs
scripts. We pass in the x- and y-tiling factors for the overlay texture because the alternative is passing in the screen height and calculating the y-tiling amount inside the fragment shader, which is less efficient (the y-resolution of the pixelated image snaps to an integer, so we need to do extra calculations here to make sure the overlay image UVs also snap to an âintegerâ). In a real-world scenario, you would recalculate the _YTileCount
whenever the screen is resized, but weâll skip that for simplicity. Now, when you run the shader youâll see the overlay matches exactly with the pixelated image.
Looking at the scene using a smaller tile count and at a different angle, youâll get different results:
Conclusion
Today weâve created a masterpiece made of mosaics. We made the effect by combining scripting features to shrink the resolution to the size we wanted with shader features to overlay a tiling texture. The result is a mosaic with an easily customisable tiling size and edge colour.
In next weekâs tutorial, weâll see how to recreate a red-blue 3D glasses effect inside Unity!
Acknowledgements
Iâd like to thank my Patreon supporters for making this content possible. Become a Patron for $1+ to receive PDF versions of all articles, or $5+ to get certain articles early!
This tutorial series uses the following asset packs - available on the Unity Asset Store:
Forest - Low Poly Toon Battle Arena / Tower Defense Pack | AurynSky |
$20 Backers
Special thanks to my $20 backers:
- Gemma Louise Ilett