按位运算符和二进制

在计算机时代的初期,二进制十六进制(十六进制)是一种生活方式,可能是因为高级语言(如BASIC)运行太慢。例如,使用BASIC实现32 x 32可能需要不同的CPU周期,但使用二进制它是在单个CPU周期内完成的单个操作。

然而,如今,即使是一台基本的PC,你也不再需要担心这一点,你可以做"长路"的事情,因为机器的速度和更复杂的CPU结构将弥补这种方法所带来的任何短时间的浪费。这当然是一个好消息,因为它意味着你不再需要优化你编写的每一行代码,但如果是这样的话-你真的应该关心二进制吗?

答案肯定是"是的,你应该"。虽然这是真的,你仍然可以得到一些速度提升-有时这些可能是显着的-使用二进制和十六进制导致更好地了解CPU如何运作,也可以导致编写更好的代码,能够更好地打包数据,这一页将解释什么是二进制以及如何在制作游戏时使用它。

注意要在GML中写入二进制文字,请在其前面加上0b(例如0b0010)。有关详细信息,请参阅数据类型

让我们先来看看最基本的二进制理论--数字是如何产生的。看看这个表:

000 = 0
001 = 1
010 = 2
100 = 4

每个1或0代表一个的数据,正如你所看到的,这意味着在二进制中,10等于2!每个位都是前一个值的2倍,第一个位等于1。所以位2 = 2,位3 = 4,位4 = 8等等(如下面的字节表所示):

00000001 = 1
00000010 = 2
00000100 = 4
00001000 = 8
00010000 = 16
00100000 = 32
01000000 = 64
10000000 = 128

如果你想要2的幂的数,这很好,但是我们如何创建更复杂的数呢?一个二进制数只能存储0或1,仅此而已,所以对于更复杂的数,我们需要将位加在一起。例如,如果我们想得到6,我们将4和2加在一起,就像这样。

00000010 = 2
00000100 = 4
00000110 = 6

这适用于所有二进制数,也适用于计算机如何在内部组成任何数字。让我们举一个稍微复杂一点的数字作为进一步的例子:23。数字23实际上是由1+2+4+1600010111组成的。一个更复杂的例子:196怎么样?嗯,那是由128+64+411000100组成的。所以实际上它并不那么复杂!

如果我们开始处理一个字节范围之外的值(它可以存储从0到255的数字),但是,它确实开始变得有点难以跟踪。例如,217,361在二进制中是110101000100010001。或者,1+16+256+等.无论表示的值是什么,规则都是相同的-每个数字都是通过将多个位相加而创建的。

现在,我们如何对这些值进行数学运算? 假设您想要将 truefalse 存储为值。 通常编译器会使用 INTINT 通常定义为带符号的 32 位数字 - 带符号仅表示它可以是正值或负值,而无符号表示它只能是正值),然后简单地赋值 将其设置为 01。 由于只有 2 个状态,true / false 值非常适合存储在位中,如果我们这样做,我们可以为每个 { 存储 32 个 true / false 位 }INT 而不仅仅是一位,因为 INT 由 32 位组成。

我们要怎么做呢?很容易就能发现:

flags = flags | 1;

该"|"运算符是按位的OR,这意味着上面的指令1与变量flags中的值进行OR。如果你还记得前面的话,使用1将设置第一位。如果我们想设置第二位,我们会这样做:

flags = flags | 2;

我们在2中OR,因为位模式0000010等于2。那么二进制OR运算符到底做什么呢?它将所有位合并为一个值,如下所示:

010110100 // Value 1
110011001 // value 2
110111101 // Value 1 OR Value 2

下面是OR运算符的真值表

00 | 00 = 00
00 | 01 = 01
01 | 01 = 01
01 | 00 = 01

因此,如果有一个值有两个零,它将保持为零。使用这样的位作为true/false状态的好处是,您可以在一个操作中设置几个位作为"标志",这是您无法使用普通布尔值完成的。例如,假设位1是"活动"标志,位3是"可见"标志。我们可以通过这样做来设置两者:

flags = flags | 5;

这是因为5在二进制中是00000101,并且遵循上述规则,变量"flags"将使这2个位与其自身合并。因此,即使位1已经被设置,操作仍然有效,并且位3现在也将被设置。

那么清除标志呢?这就是按位"&"AND操作的用武之地。当您执行AND操作时,掩码中设置的位将保留,而掩码中清除的位将被删除-如下所示:

01110010101 // Value 1
00110000100 // Value 2
00110000100 // Value 1 AND value 2

正如你所看到的,每个值中有1的地方,1被保留,如果有0和1的混合,这些被重置为0。下面是AND的真值表:

00 & 00 = 00
01 & 00 = 00
00 & 01 = 00
01 & 01 = 01

因此,只有当每个位置都有一个位时,它才会被保留。这意味着,正如您可以一次设置多个标志一样,您也可以一次清除多个标志。例如,让我们以上面的情况为例,但这次要清除它们。我们要清除位1和3(给我们值5),但是在记住上面的真值表时,我们要做的是保留所有其他位,并且清除位1和3。这将是11111111111111111111111010(32位)的二进制"掩码"。该掩码保持当前设置的所有位,但是清除了我们实际上想要清除的两个位。所以如果有一个值1000111011,我想用上面的掩码清除第1位和第3位,结果会是这样的。

00000000000000000000001000111011 // Value
11111111111111111111111111111010 // Mask
00000000000000000000001000111010 // Value AND Mask

这很好,但是如果我们每次需要清除标志时都要解决这个问题,这会变得很烦人。我们需要的是一种轻松翻转位的方法(最好没有CPU成本)。幸运的是,有一种简单的方法可以通过使用"~"NOT操作符来实现。

NOT运算符就是它所说的-not那些位。这里有一个NOT的真值表。

~00 = 11
~01 = 10
~10 = 01
~11 = 00

这个操作符使得移除标志变得非常简单,更好的是,它通常是一个编译时优化,这意味着如果你使用常量(即不是变量),那么编译器将自动为你翻转位。在我们想要再次清除位1和3的地方使用这个语句:

a = a & ~5;

这实际上将编译为"a & 11111111111111111111010"。这使得清除标志的过程非常简单。

我们要看的最后一个运算符是"^"EOR(异或,有时称为XOR),该运算符翻转两个值中设置的位。下面是EOR真值表:

0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0

这是一个奇怪的问题,但是非常有用。例如,假设我们想要一个计数器,它可以简单地从0到1再回到0(在0和1之间切换),我们可以添加1并执行IF以查看它是否达到2,然后将其重置为1。或者.我们可以添加1,然后和1(因为01+01 = 1010& 01 = 0)或者我们可以这样做:

a = a ^ 1;

它第一次运行时的情况是0 ^ 1 = 1,第二次运行时是1 ^ 1 = 0,从而在0和1之间来回切换。

因此 - OR (|)、AND(&)、NOT(~) 和 EOR(^)让我们相对轻松地操作位,使我们能够在最简单的层面上同时控制多个位。 显然,我们在开发游戏时可以将这些操作用于其他用途,例如屏蔽精灵、执行整数 MOD 操作(使用 AND)或执行良好的循环计数器。

所以,我们可以做简单的按位运算,但让我们看看更复杂的东西,从问题开始,计算机如何加法?让我们看一个非常简单的例子:1+1

00000001
00000001
00000010

就像普通的加法一样,我们把数字加在一起,然后溢出到下一列,但与普通的十进制加法不同,我们只能从0到1,而不是从0到9。所以添加1+1意味着我们溢出到10。让我们看一个更复杂的例子。

01011011 = 91
00101101 = 45
10001000 = 136

显然这里很难看到,但溢出会一直蔓延,直到一列中没有溢出为止。 值得注意的是,计算机一次只能对 2 个数字进行加(或减、乘或除)。 取 19 + 19 + 19。 作为人类,我们可以将所有 9 加在一起,携带 2,然后继续! 但计算机无法做到这一点 - 它们能做到的是:(19 + 19) + 19。 因此他们将以 2 为一组进行每次计算。

作为程序员,我们最感兴趣的二进制计算是乘法和除法。计算机只在2秒内相乘,要做更多的运算,它会将一个数字分开,然后将所有结果加在一起。让我们先看一些非常简单的例子。4 * 2 = 8。现在要在二进制中乘以2,我们所有位向左移1。像这样:

00000100 * 2 = 00001000 = 8

在这种情况下,所有的位都向左移动了一位,使它从第3位移动到第4位,并将值从4更改为8。

101 = 01100101 * 2 = 11001010 = 202

同样,所有的位都移到1上,然后乘以2。那么,乘以4怎么样?很简单,我们把所有的东西都左移2,而不是1。那么16或128怎么样?这将分别需要左移4位或7位。这是非常有用的;这意味着我们可以通过简单地移动位来完成简单的乘法。为此,我们使用左移运算符<<。下面是一些例子:

00000001 << 1 = 000000010 = 2
00000001 << 2 = 000000100 = 4
00000001 << 3 = 000001000 = 8
00000001 << 4 = 000010000 = 16
00000001 << 5 = 000100000 = 32
00000001 << 6 = 001000000 = 64
00000001 << 7 = 010000000 = 128
00000001 << 8 = 100000000 = 256

现在,除了对于快速/简单的乘法非常有用之外,它对于设置特定的位也非常有用,而不必计算位值。假设我们想要设置位27,那是什么数字?(顺便说一下,67108864!),那么我们可以使用上面的语法来轻松地设置标志如下:

a = a | (1 << 27)

好的,实际上这是第26位,我们到目前为止描述的方式(因为位从1开始),但实际上.位从0开始,向上,而不是从1开始。因此,虽然INTEGER中有32位,但位的范围是从0到31,而不是1到32。这实际上非常有用,因为我们现在可以为位数设置常数。

假设第27位是一个有效标志,第0位是一个爆炸标志,我们如何同时设置这两个标志?

ACTIVE = 27;
BOOM = 0;
A = A | (1 << ACTIVE) | (1 << BOOM);

这可能看起来像很多代码,但如果这些数字是常量,编译器将把这些操作预编译成一个值,这样我们就可以得到实际的代码。

A = A | 13421772;

清除这些位(正如我们上面看到的)只是使用NOT修饰符的问题,如下所示:

A = A & ~((1 << ACTIVE) | (1 << BOOM));

所以这让我们很高兴地设置和清除任何我们想要的位,它也让我们大规模地压缩数据结构。压缩数据结构是一件好事,因为如果你使用更少的内存,你会得到更少的缓存未命中,你的代码会运行得更快。这样说吧,复制32 Mb或数据,还是4 Mb,哪个更快?很明显,4是。所以如果你能把所有的标志都打包到一个内存访问中,这很好!

现在,让我们快速了解一下如何进行除法,以及为什么它会如此有用。除法是通过使用right shift运算符将位右移来完成的。让我们取一个简单的数字- 64 -并将其除以32:

64 / 32 = 01000000 >> 5 = 00000010

因此,您将单个位向下移动5(这是32所需的移位次数-如上所述),这使我们得到2。但是如果这里有其他位会发生什么?让我们来看看:

68 / 32 = 01000100 >> 5 = 00000010

这就是了.其中网格大小为32 x32。此方法允许存储对象,冲突,标志-所有类型的东西,并非常快速地访问它们。所以我们开始:

var X_index = x >> 5;
var Y_index = y >> 5;
cell_data = mygrid[# X_index,Y_index];

那么,如果我们想要余数呢?也许这个余数被用作某种顺序标志或其他东西。不管是什么原因,获得余数就像做AND一样简单:

var remainder = x & 31
var X_Index = x >> 5;

现在,你可能已经注意到我们在这里使用了多行代码(这是经常发生的情况),但这仍然只是几个非常快的指令。但是为什么是31?嗯,因为第5位是32,那么下面的所有位都是31,这是最大的余数,所以这就是我们AND的内容(我们也可以使用(1 5)- 1,这将使32 - 1 = 31)。现在,如果我在不理解二进制的情况下这样做,它看起来像这样:

var r = x mod 32;
var X_Index = floor(x / 32);

那么,为什么这更糟糕呢?好吧,为了除以32,我们必须执行一个浮点除法-这显然需要时间,但是为了做mod 32,你实际上必须做另一个!如果我们在汇编中这样做,我们实际上会在一次除法中得到两个值,但你不会在高级语言中得到这个(嗯.不经常),所以你必须做所有的工作两次.这加起来,特别是如果你正在做一个紧密的循环与大量的计算像这样.

由于这可能是一个相当复杂的概念,难以掌握,然后应用于现实世界的编程情况,您可以在下面找到一系列简短的示例,这些示例可以应用于任何使用GameMaker制作的游戏。

平铺对齐平铺对齐

GameMaker 开发者经常使用函数 place_free(),然后当发现碰撞时,尝试通过围绕 xy 位置循环来缓慢移出对象 继续执行该函数,或使用 move_outside_all() 函数。

有什么更快的方法吗?好吧,如果我们使用适当的2的幂瓷砖,那么我们有一个非常简单的方法,也是闪电般的快。如果我们向右移动,我们已经移动到一个碰撞块,那么我们知道一切都对齐32,所以我们还需要将精灵与32像素的边界对齐-最好是左边的边界-这样精灵就被移出了碰撞。这真的很容易,知道了我们上面用来求余数的规则,知道了如何求位的倒数,我们可以简单地这样做:

x = x & ~31;

没错,这就是对齐到32像素边界所需的全部内容。通过改变31,我们可以对齐到任何我们喜欢的东西-只要它是2的幂。(这相当于除以32,然后乘以32,从而删除低位。)

如果我们想向右对齐,那么我们可以执行上面的操作,但是添加32将其移动到下一个图块。很简单。所有这些都使整个冲突代码非常快,并且让您将CPU时间花在真正需要的地方。

 

钥匙和门钥匙和门

假设你有一个有几扇门的关卡,每个门都有一把钥匙。你怎么能轻松地为特定的门标记一把钥匙呢?嗯,通常你只会为钥匙和门分配一个ID。那么如果你想要一把钥匙打开2或3扇门呢?很简单。你使用MASK。门会像这样分配一个位:

door_id = 1; // 0001

其他的类似于:

door_id = 2; // 0010
door_id = 4; // 0100
door_id = 8; // 1000
// etc...

如果我们想让钥匙打开门1和3,那么钥匙的MASK值应该是5(二进制中是101)。如果我们执行AND,结果是"非零",那么我们知道钥匙可以打开门。你也可以通过MASK为0来让钥匙什么也开不了。请参阅下面的代码来进行实际检查:

if ((key_id & door_id) ! = 0)
{
    opendoor();
}

 

循环计数器循环计数器

假设我们想要一个简单的动画计数器,从0到15(因为我们有16帧动画)。通常你会做一个增量,然后做一个if检查来包装数字,但是在这个例子中,让我们使用AND()运算符:

counter = (counter + 1) & 15;

由于16是2的幂,我们可以将该数字减少1并将其用作掩码,然后我们可以使用该掩码来包装计数器值。如果计数器从15上升到16,我们最终得到位模式10000,如果我们AND与15(位模式01111),我们最终得到00000(简单的零)。这意味着上面的代码对于包装2的幂范围内的值很有用。

 

2次幂校验2次幂校验

如果你想检查某个值是否是2的幂呢?好吧,这里有一个巧妙的小技巧。.如果给定的值是2的幂,这将返回true

function is_pow2(_val)
{
    return _val & (_val - 1)) == 0;
}

那么,如果我们有数字 51 (110011),它会做什么? 好吧,我们得到了这个...110011 & 110010,这显然给我们带来了 false,因为 AND 之后还剩下很多位。 如果我们有 64 (1000000),那么它会变成这样...1000000 & 0111111,这确实剩下 0,所以它是正确的

 

索引对齐索引对齐

这里有一段快速的代码来对齐2的幂。(1,2,4,8,16等等)。这对于内存分配或确保将数据写入正确的边界非常有用。在本例中,_val1需要与_val2字节对齐,其中_val2是2的幂。这个小函数向上舍入到所需数字的下一个边界。

function align_pow2(_val1, _val2)
{
    return _val1 + (_val2 - 1)) & ~(_val2 - 1);
}