Introduction
Ever wondered how long is the moving background picture of a video game? You must have noticed that at some point, it just keeps repeating, and this is what we are going to learn in this article.. Two days ago, I was in a park with my toddler when I noticed the way the footpath tiles are placed, making up an endless repeating shape, and that's when I decided to look up this problem in a mathematical approach.
The Illusion
Manipulating images through colors and textures has always been used in tricking our minds into giving an implied illusion. To make the tiling illusion, the first problem to tackle is to make a picture seamlessly "tileable" so that its ends fit to make up part of the overall picture by repeating part of it.. In other words, it's about placing a piece of texture next to each other without having a visible seam, and making sure not to have any eye-catching repeating spots in the overall picture.
It might be useful to say that not any picture will do, you need to have some sort of a repetitive pattern, at least at the edges of the picture. Now that the picture has been chosen, make sure you cover two basic requirements:
- The picture should be taken in a 90° angle.
- There shouldn't be much visible lightning scales in different parts of the picture.
Tip: To easily spot different lightning scales in a picture, try applying a gray-scale filter and then darken/lighten the different parts as required.
Now that our picture is ready, we have two options in the way we may tile it. We might want to tile it only in one dimension, i.e., horizontally/vertically so the picture fits in a row/column, or we could make it completely tileable by making sure that all ends match up. However, if you're intending to make a fully tileable picture, you'd better make it a perfect square of a size based on a power of 2 (i.e., 2^5 = 32), for two main reasons:
- Many commercial imaging filters (most notably clouds) will create already seamless tiles, but only if the original image is based on a power of 2.
- Powers of two are easy to manage and check when it comes to pictures, and even your monitor resolution is based on a power of two!
Background
To make the picture tileable, the first thing you need to do is to offset it horizontally and/or vertically, preferably by 50% of its original dimensions. This is best demonstrated by means of a picture. So in a picture like shown below, the creepy alien seems to be nudged over from the picture to reappear on the other side of it. "Sorry Bob".
Now that we have made sure the edges match seamlessly, let's see how the endless wrapping works.. We need to look at the picture in a different way, recognizing the overall image as a beam of columns and rows of pixels, those when meet will make a perfect cylinder.
Now, to make an illusion of a picture moving to the right, we simply iterate through the columns of the picture, taking the most right column or columns; depending on how smooth the movement of the picture should be (the more columns you take out in a single movement, the less smoothness the movement of the picture will have), we take out that column(s), shift the rest of the picture to exactly cover the gap created by the taken column(s), and then we reattach those columns back to the picture, but to the gap created in the beginning of the picture when we moved (Clip1) to cover for the taken out columns (Clip2) in the first place. The following pictures show the movement technique by indexing the pixels of a sample picture of a 4X4.
Notice the shift of the indexes in the blue to move the respective image from left to right, and the green one to give the illusion of a downward moving picture. Also notice that if more than one column is moved, the series still maintains to keep ordered in a contiguous manner.
Using the Code
I'm going to explain only the endless ocean solution as it covers the wrapping trick for both dimensions.
Note that you can change the direction of both samples by holding down the Control key and pressing the respective Arrow key.
To move the picture, we first need to clip it as explained above, and here is the function to do it:
private Bitmap Crop(Bitmap srcBitmap, Rectangle rectClip)
{
Bitmap tmpBmp = new Bitmap(rectClip.Width, rectClip.Height);
Graphics g = Graphics.FromImage(tmpBmp);
g.DrawImage(srcBitmap, 0, 0, rectClip, GraphicsUnit.Pixel);
g.Dispose();
return tmpBmp;
}
The function takes the source bitmap as its first parameter, and the rectangle to cut out, considering the start location of the clipping and the dimensions, then returns that part as a Bitmap
object.
We also use some user controls inherited from the PictureBox
class to support background transparency.
using System;
using System.Windows.Forms;
namespace UI_Test
{
public class TransparentPictureBox : PictureBox
{
public TransparentPictureBox()
{
this.SetStyle(ControlStyles.Opaque, true);
this.SetStyle(ControlStyles.OptimizedDoubleBuffer, false);
}
protected override CreateParams CreateParams
{
get
{
CreateParams parms = base.CreateParams;
parms.ExStyle |= 0x20;
return parms;
}
}
}
}
Once compiled, you'll see the control added to your solution components list at the top of the toolbox pane with the label TransparentPictureBox
.
Now, let's take a look at the entire code listing and discuss each part separately.
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Media;
namespace SampleGameII
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
private enum Style
{
Left,
Right,
Up,
Down
}
private void tmr_MoveBG_Tick(object sender, EventArgs e)
{
if (!chReverse.Checked)
PIC1.Image = MoveImage((Bitmap)PIC1.Image.Clone(), iSpeed, sDirection);
else
{
if (iSpeed >= Properties.Resources.Ocean.Width - iSpeed)
iSpeed = 3;
else
iSpeed += 3;
PIC1.Image = MoveImage(Properties.Resources.Ocean, iSpeed, sDirection);
}
Spaceship.Refresh();
}
private Bitmap Crop(Bitmap srcBitmap, Rectangle rectClip)
{
Bitmap tmpBmp = new Bitmap(rectClip.Width, rectClip.Height);
Graphics g = Graphics.FromImage(tmpBmp);
g.DrawImage(srcBitmap, 0, 0, rectClip, GraphicsUnit.Pixel);
g.Dispose();
return tmpBmp;
}
private Bitmap MoveImage(Bitmap srcBitmap, int iMargin, string sDirection)
{
Bitmap tmpBmp, Clip1, Clip2;
tmpBmp = Clip1 = Clip2 = null;
switch (sDirection)
{
case "Left":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width - iMargin, srcBitmap.Height)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(srcBitmap.Width - iMargin, 0),
new Size(iMargin, srcBitmap.Height)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, iMargin, 0,
srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip2, 0, 0, iMargin, srcBitmap.Height);
}
else
{
g.DrawImage(Clip2, iMargin, 0,
srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip1, 0, 0, iMargin, srcBitmap.Height);
}
g.Dispose();
break;
}
case "Right":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(iMargin, 0),
new Size(srcBitmap.Width - iMargin, srcBitmap.Height)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(iMargin, srcBitmap.Height)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, 0, srcBitmap.Width - iMargin,
srcBitmap.Height);
g.DrawImage(Clip2, srcBitmap.Width - iMargin, 0,
iMargin, srcBitmap.Height);
}
else
{
g.DrawImage(Clip2, 0, 0, srcBitmap.Width - iMargin,
srcBitmap.Height);
g.DrawImage(Clip1, srcBitmap.Width - iMargin, 0,
iMargin, srcBitmap.Height);
}
g.Dispose();
break;
}
case "Up":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width, srcBitmap.Height - iMargin)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, srcBitmap.Height - iMargin),
new Size(srcBitmap.Width, iMargin)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, iMargin, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip2, 0, 0, srcBitmap.Width, iMargin);
}
else
{
g.DrawImage(Clip2, 0, iMargin, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip1, 0, 0, srcBitmap.Width, iMargin);
}
g.Dispose();
break;
}
case "Down":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, iMargin),
new Size(srcBitmap.Width, srcBitmap.Height - iMargin)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width, iMargin)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, 0, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip2, 0, srcBitmap.Height - iMargin,
srcBitmap.Width, iMargin);
}
else
{
g.DrawImage(Clip2, 0, 0, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip1, 0, srcBitmap.Height - iMargin,
srcBitmap.Width, iMargin);
}
g.Dispose();
break;
}
}
return tmpBmp;
}
int iSpeed;
string sDirection;
private void Form1_Load(object sender, EventArgs e)
{
sDirection = "Up";
iSpeed = 3;
tmr_MoveBG.Start();
}
private void Form1_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Left || e.KeyCode == Keys.Right
|| e.KeyCode == Keys.Up || e.KeyCode == Keys.Down)
{
sDirection = e.KeyCode.ToString();
iSpeed = 3;
tmr_MoveBG.Start();
Bitmap bm;
bm = new Bitmap(Properties.Resources.Spaceship2);
if (e.KeyCode == Keys.Right)
bm.RotateFlip(RotateFlipType.Rotate90FlipNone);
if (e.KeyCode == Keys.Left)
bm.RotateFlip(RotateFlipType.Rotate270FlipNone);
if (e.KeyCode == Keys.Down)
bm.RotateFlip(RotateFlipType.Rotate180FlipNone);
Spaceship.Image = bm;
}
}
private void chReverse_CheckedChanged(object sender, EventArgs e)
{
if (!chReverse.Checked)
{
PIC1.Image = Properties.Resources.Ocean;
tmr_MoveBG.Stop();
iSpeed = 3;
tmr_MoveBG.Start();
}
}
private void tbSpeed_Scroll(object sender, EventArgs e)
{
tmr_MoveBG.Interval = 50 - (tbSpeed.Value * 5);
lblSpeed.Text = "Speed: "+ (tbSpeed.Value+1).ToString();
}
}
}
The motion illusion is initiated and carried out using the tmr_MoveBG
timer. The MoveImage
function is called with every tick of the timer, passing a clone of the currently modified Image
of the picture box and assigning it back to the PictureBox
control to be passed again with the next tick.
private void tmr_MoveBG_Tick(object sender, EventArgs e)
{
if (!chReverse.Checked)
PIC1.Image = MoveImage((Bitmap)PIC1.Image.Clone(), iSpeed, sDirection);
else
{
if (iSpeed >= Properties.Resources.Ocean.Width - iSpeed)
iSpeed = 3;
else
iSpeed += 3;
PIC1.Image = MoveImage(Properties.Resources.Ocean, iSpeed, sDirection);
}
Spaceship.Refresh();
}
The MoveImage
function takes the source image as its first parameter as a Bitmap
, along with the margin to clip and the desired direction to move to.
The direction parameter is evaluated through a switch case
block to clip and redraw the right image, and then finally returns it to be set back to the PictureBox
control, which will be taken again as a clone to be the new source image to pass to the function.
private Bitmap MoveImage(Bitmap srcBitmap, int iMargin, string sDirection)
{
Bitmap tmpBmp, Clip1, Clip2;
tmpBmp = Clip1 = Clip2 = null;
switch (sDirection)
{
case "Left":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width - iMargin, srcBitmap.Height)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(srcBitmap.Width - iMargin, 0),
new Size(iMargin, srcBitmap.Height)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, iMargin, 0,
srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip2, 0, 0, iMargin, srcBitmap.Height);
}
else
{
g.DrawImage(Clip2, iMargin, 0,
srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip1, 0, 0, iMargin, srcBitmap.Height);
}
g.Dispose();
break;
}
case "Right":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(iMargin, 0),
new Size(srcBitmap.Width - iMargin, srcBitmap.Height)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(iMargin, srcBitmap.Height)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, 0, srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip2, srcBitmap.Width - iMargin, 0,
iMargin, srcBitmap.Height);
}
else
{
g.DrawImage(Clip2, 0, 0, srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip1, srcBitmap.Width - iMargin, 0,
iMargin, srcBitmap.Height);
}
g.Dispose();
break;
}
case "Up":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width, srcBitmap.Height - iMargin)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, srcBitmap.Height - iMargin),
new Size(srcBitmap.Width, iMargin)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, iMargin, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip2, 0, 0, srcBitmap.Width, iMargin);
}
else
{
g.DrawImage(Clip2, 0, iMargin, srcBitmap.Width,
srcBitmap.Height - iMargin);
g.DrawImage(Clip1, 0, 0, srcBitmap.Width, iMargin);
}
g.Dispose();
break;
}
case "Down":
{
tmpBmp = new Bitmap(srcBitmap.Width, srcBitmap.Height);
Clip1 = Crop(
srcBitmap,
new Rectangle(
new Point(0, iMargin),
new Size(srcBitmap.Width, srcBitmap.Height - iMargin)));
Clip2 = Crop(
srcBitmap,
new Rectangle(
new Point(0, 0),
new Size(srcBitmap.Width, iMargin)));
Graphics g = Graphics.FromImage(tmpBmp);
if (!chReverse.Checked)
{
g.DrawImage(Clip1, 0, 0, srcBitmap.Width, srcBitmap.Height - iMargin);
g.DrawImage(Clip2, 0, srcBitmap.Height - iMargin,
srcBitmap.Width, iMargin);
}
else
{
g.DrawImage(Clip2, 0, 0, srcBitmap.Width, srcBitmap.Height - iMargin);
g.DrawImage(Clip1, 0, srcBitmap.Height - iMargin,
srcBitmap.Width, iMargin);
}
g.Dispose();
break;
}
}
return tmpBmp;
}
In order to change the direction of the image, we capture the keyCode
and assign it as a string
to the sDirection
variable and simply flip the spaceship image to match the new direction. Notice that the background image changes its direction as the sDirection
variable value changes, for the timer is still running, and uses it to determine the moving direction.
private void Form1_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Left || e.KeyCode == Keys.Right
|| e.KeyCode == Keys.Up || e.KeyCode == Keys.Down)
{
sDirection = e.KeyCode.ToString();
iSpeed = 3;
tmr_MoveBG.Start();
Bitmap bm;
bm = new Bitmap(Properties.Resources.Spaceship2);
if (e.KeyCode == Keys.Right)
bm.RotateFlip(RotateFlipType.Rotate90FlipNone);
if (e.KeyCode == Keys.Left)
bm.RotateFlip(RotateFlipType.Rotate270FlipNone);
if (e.KeyCode == Keys.Down)
bm.RotateFlip(RotateFlipType.Rotate180FlipNone);
Spaceship.Image = bm;
}
}
The reversed effect is decided by checking the "Reverse Effect" check box which simply restarts the moving timer to use the new settings:
private void chReverse_CheckedChanged(object sender, EventArgs e)
{
if (!chReverse.Checked)
{
PIC1.Image = Properties.Resources.Ocean;
tmr_MoveBG.Stop();
iSpeed = 3;
tmr_MoveBG.Start();
}
}
The new setting for the reverse effect is handled by switching the placement of the clips so that the bigger clip (Clip 1) takes the place of the smaller one (Clip 2) and vice versa, and hence the moving stretch effect to fit the clips in the wrong sizes.
if (!chReverse.Checked)
{
g.DrawImage(Clip1, iMargin, 0, srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip2, 0, 0, iMargin, srcBitmap.Height);
}
else
{
g.DrawImage(Clip2, iMargin, 0, srcBitmap.Width - iMargin, srcBitmap.Height);
g.DrawImage(Clip1, 0, 0, iMargin, srcBitmap.Height);
}
Finally, we determine the speed of the movement by increasing/decreasing the interval value of the timer.
private void tbSpeed_Scroll(object sender, EventArgs e)
{
tmr_MoveBG.Interval = 50 - (tbSpeed.Value * 5);
lblSpeed.Text = "Speed: "+ (tbSpeed.Value+1).ToString();
}
Of course, we could have increased the number of columns taken out in a clip, sacrificing some of the smoothness by increasing the margin in the MoveImage
function.
PIC1.Image = MoveImage((Bitmap)PIC1.Image.Clone(), iSpeed, sDirection);
Note that the iSpeed
value is predetermined in this example in the form_load
event along with the sDirection
value.
private void Form1_Load(object sender, EventArgs e)
{
sDirection = "Up";
iSpeed = 3;
tmr_MoveBG.Start();
}
Points of Interest
A major drawback of this technique is that the newly drawn image obscures the overlaying PictureBox
image, and hence the timer keeps refreshing it with every tick, which generates this inconvenient flickering of the image.
private void tmr_MoveBG_Tick(object sender, EventArgs e)
{
...
Spaceship.Refresh();
}