游戏中的图像包装技巧






4.79/5 (35投票s)
解释视频游戏中使用的无尽图片循环技巧。
- 下载 ED 二进制文件 - 1.17 MB
- 下载 ED 源代码 - 99.5 KB
- 下载 Endless Ocean 二进制文件 - 3.54 MB
- 下载 Endless Ocean 源代码 - 95.6 KB
引言
你是否曾想过视频游戏中不断移动的背景图片到底有多长?你一定注意到在某个时候,它会不断重复,这正是我们在本文中要学习的内容。两天前,我和我的孩子在公园里,注意到人行道砖块的铺设方式,形成了一个无限重复的形状,那时我决定从数学方法来研究这个问题。
错觉
通过颜色和纹理来操控图像一直是用来欺骗我们的大脑,从而产生一种隐含的错觉。为了制造平铺的错觉,首先要解决的问题是让一张图片无缝地“平铺”,使其边缘能够吻合,通过重复其中的一部分来构成整体的图像。换句话说,就是在不留下可见接缝的情况下,将一块纹理放在另一块旁边,并确保整体图像中没有引人注目的重复点。
值得一提的是,并非所有图片都适用,你需要有一些重复的模式,至少在图片的边缘。现在图片已经选好,请确保你满足两个基本要求:
- 图片应以 90° 角拍摄。
- 图片的不同部分不应有明显的光照差异。
提示:为了轻松识别图片中不同的光照差异,可以尝试应用灰度滤镜,然后根据需要调整不同部分的亮度/暗度。
现在我们的图片已经准备好,在平铺时我们有两种选择。我们可以选择只在一维上平铺,即水平/垂直平铺,这样图片就能形成一行/一列,或者通过确保所有边缘都匹配来使其完全可以平铺。然而,如果你打算制作一张完全可以平铺的图片,最好将其制作成一个基于 2 的幂(即 2^5 = 32)的完美正方形,原因主要有两点:
- 许多商业图像滤镜(尤其是云彩滤镜)可以创建无缝的平铺,但前提是原始图像必须基于 2 的幂。
- 2 的幂次方在处理图片时易于管理和检查,甚至你的显示器分辨率也是基于 2 的幂!
背景
为了使图片可以平铺,你需要做的第一件事是将其水平和/或垂直偏移,最好是其原始尺寸的 50%。这最好通过图片来演示。例如,在下面这张图片中,恐怖的外星人似乎从图片的一侧移出,并在另一侧重新出现。“抱歉,鲍勃”。
既然我们已经确保边缘无缝匹配,让我们来看看无尽循环是如何工作的……我们需要以不同的方式看待这张图片,将整个图像看作是由像素列和行组成的,这些像素相遇时会形成一个完美的圆柱体。
现在,为了产生向右移动的图片效果,我们只需遍历图片的列,取出最右边的一列或几列;这取决于移动的平滑度(一次移动的列越多,图片移动的平滑度就越低),然后取出这些列,将剩余的图片向左移动以完全填补取出的列造成的空隙,然后我们将取出的列重新附加到图片的开头,填补之前为了覆盖取出的列(Clip2)而移动(Clip1)所造成的空隙。接下来的图片通过对一个 4X4 的样本图片的像素进行索引来展示移动技术。
请注意蓝色区域索引的移动,以将相应图像从左向右移动,绿色区域索引则用于产生向下移动的图片的错觉。同时请注意,如果移动了多于一列,序列仍然保持连续排列。
Using the Code
我将只解释 Endless Ocean 的解决方案,因为它涵盖了两个维度的循环技巧。
请注意,您可以通过按住 Control 键并按下相应的箭头键来更改两个样本的方向。
为了移动图片,我们首先需要按照上述说明进行裁剪,这是执行此操作的函数:
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;
}
该函数将源位图作为第一个参数,并将指定的矩形区域剪切出来,考虑到裁剪的起始位置和尺寸,然后将该部分作为 Bitmap
对象返回。
我们还使用了一些从 PictureBox
类继承的用户控件来支持背景透明。
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;
}
}
}
}
编译后,您将在工具箱窗格顶部的解决方案组件列表中看到该控件,标签为 TransparentPictureBox
。
现在,让我们看一下完整的代码列表,并分别讨论每个部分。
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();
}
}
}
运动的错觉是通过 tmr_MoveBG
定时器启动和执行的。每次定时器滴答时,都会调用 MoveImage
函数,并将图片框当前修改的 Image
的克隆传递回去,并将其分配给 PictureBox
控件,以便在下一次滴答时再次传递。
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();
}
MoveImage
函数将源图像作为 Bitmap
对象作为第一个参数,以及要裁剪的边距和所需的移动方向。
方向参数通过 switch case
块进行评估,以裁剪和重绘右侧图像,最后将其返回设置回 PictureBox
控件,该控件将再次被用作克隆,成为传递给函数的新的源图像。
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;
}
为了改变图像的方向,我们捕获 keyCode
并将其作为 string
分配给 sDirection
变量,然后简单地翻转飞船图像以匹配新的方向。请注意,背景图像会随着 sDirection
变量值的改变而改变方向,因为定时器仍在运行,并使用它来确定移动方向。
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();
}
}
反转效果的新设置是通过切换剪辑的位置来处理的,这样较大的剪辑(Clip 1)就占据了较小剪辑(Clip 2)的位置,反之亦然,从而产生移动拉伸效果以适应不匹配大小的剪辑。
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);
}
最后,我们通过增加/减少定时器的间隔值来确定移动速度。
private void tbSpeed_Scroll(object sender, EventArgs e)
{
tmr_MoveBG.Interval = 50 - (tbSpeed.Value * 5);
lblSpeed.Text = "Speed: "+ (tbSpeed.Value+1).ToString();
}
当然,我们可以增加每次剪辑时取出的列数,通过在 MoveImage
函数中增加边距来牺牲一些平滑度。
PIC1.Image = MoveImage((Bitmap)PIC1.Image.Clone(), iSpeed, sDirection);
请注意,在本例中 iSpeed
值已在 form_load
事件中与 sDirection
值一起预先确定。
private void Form1_Load(object sender, EventArgs e)
{
sDirection = "Up";
iSpeed = 3;
tmr_MoveBG.Start();
}
关注点
此技术的一个主要缺点是新绘制的图像会遮盖住上方的 PictureBox
图像,因此定时器在每次滴答时都会不断刷新它,从而导致图像闪烁,这是很不方便的。
private void tmr_MoveBG_Tick(object sender, EventArgs e)
{
...
Spaceship.Refresh();
}