从NAND到Raytracer:在黑客计算机上的横向跟踪(壮举)

2021-06-21 03:19:06

我最近花了一些时间经历了惊人的NAND2TETTRIS课程,并且分配#9是用在黑客计算机上运行的千斤顶编程语言来实现。我的朋友鼓励我尝试编写一个射线跟踪,这是一种算法,即通过模拟OFOMES的光线撞击光线来获得物理上的图像。雷道德的输出是一个美丽的图像,具有阴影,反射和其他物理现象。实现雷干机需要升级数学和线性代数,并且似乎是一个有趣的挑战,与杰克语言的有限功能写入。

我想分享我的方法以及我面临的挑战,重新实现我的OWSMATH系统,并用有限的工具优化Jack程序。虽然您可能需要执行此操作,但您可能会学会计算机如何执行数学,并对数学库进行欣赏!

首先,我看着杰克语言必须提供什么。我知道Debugging会真的很具有挑战性,所以我正在寻找调试,打印记录语句和设置断点的容易程度。

答案是:不是很容易。系统有一个512x256黑白屏幕,当您使用输出来打印它来打印它,它会发出Ontothe屏幕,而不是单独的终端,它不会滚动。你找到断点,但它有点棘手才能进入和摆脱职能。 Forexample,这里是屏幕上的“Hello World”。

这与表现问题一样,导致我考虑写入雷干的测试,以便我能够快速练习实现,迭代,并在将其转换为杰克之前使其工作。我选择了这一点,因为我一直想学习生锈,因为它似乎是善良的。 Rust精确控制复制语义和INT尺寸,自动内存管理,不可变的编码和方便特征,即INTS的流出功能。

要编写生锈雷道仪,我主要遵循这种令人敬畏的博客,这是对黑客计算机的一些简化和修改。由于它具有512x256黑白屏幕(无灰度),输出保真度非常相望,因此我选择不依赖于反思的猛虎师的东西,因为无论如何都不会是非常可见的。其次,我只需要在一个通道中进行所有色彩跟踪,因为我没有颜色。最后,我知道我知道某种抖动算法将雷尺的灰度输出转换为黑色和白色像素INA漂亮的方式。

你认为我只能遵循教程,使这些更改,渲染渲染者美丽的形象,并开始在这个博客帖子上工作,但这不是thecase。首先,我不得不解决一个可以说的甚至更难的问题:杰克哈斯没有小数点类型,只有整数。此外,jack支持的唯一数字是签名的16位整数(Rifor中的I16),它从-32768到32767的存储值。这个......不是很大。如此:通过将200乘200乘200乘200乘来,可以通过200乘200。

为了实现雷干来,您需要一个相当完整的数学库,包括乘法,分裂,平方根和π。杰克有Math.SQRT,但它只在本地int类型上工作,截断到最近的int。

当然,Rust拥有我需要的所有数学函数,但如果我使用特定于锈病函数,它不会是一个非常乐观的测试实现。因此,我只需要只使用插孔中存在的生锈部分,并实现新的PartsMyself以将它们移植到千斤顶。

所以,在编写一行的雷路器代码之前,我需要写下我的overnumerical类型,足以支持我想要的范围和精度。我决定将其分成两个步骤:实现一个足够大的int课程,然后实现一个使用定点算法在其顶部的小数点类型。

固定点算术背后的想法是您可以在小数点后使用一些存储到存储区域。切换到标准基数10秒,考虑一个场景,其中我只有5位存储的存储器。我可以使用它寄存0到99999,或者我可以在第三盏之后精神上插入小数点,这让我存储0到999.99。在这种情况下,我有三位数字和两位数的精度。为了实际存储数据,我乘以100,被称为缩放因子,因为您存储X * 100 TorePresent x:34832,适用于348.32等。当缩放因子为100时,Precision是0.01,因为您的系统最小的增量为0.01,因为您的系统最小的增量步骤为0.01,更小的丢失。

当缩放因子较高时,您可以存储更高精度的小数,但是丢失整体范围。例如,具有1000的缩放因子,这是指34832为34.832,您现在有三位数的小数,但只有在小数点之前只有Twigits,所以您的范围仅为0-99。

总体的想法的想法是您必须将五位数字分配到小数或后级点数字,而且它是距离精度之间的权衡。后来我会进入这个!

返回构建INT类,执行此操作的主要技术是TOSTORE整数的数组。第一个整数是最重要的块,下一个是下一个重要的块等。它就像一个使用的方式表达了更大的数字。

在我的Rust实现中,我使用I16S(签名16位整数)对于每个,因为我想模拟等效的插孔类型int。

这是一个具有四个i16s数组的结构。我打电话给它int32,因为它的符号32位整数是足够的范围来支持所有无罪的数学。它需要在将来支持小数,如上所述,即将分配这些32位以在DECIMALPOINT之前或之后存储数据。我将在下一节进入更多。目前,我们只需要在此Int32类上实现数学来实现自己。

您可能会注意到,我似乎有多于我需要的数据:四个I16的数组是64位信息,而不是32。这是真的。我故意留下每个I16空的八个比特,这意味着每个I16在范围中[0,255]。我有几个原因如此:

我需要使用i16数学来实现Int32数学,并且它真的是Helpfulto有一些溢出空间。例如,如果我使用整个I16范围,并且最终增加30000到5000,它将溢出并缠绕到-30534,这是不可取的。相反,如果我只使用i16的“底部”一半存储0到255,我可以添加200到100并获得300,然后手动填写溢出自己。

杰克没有无符号整数,这使得数学麻烦。大多数施泰斯,我即将仅在未签名的整数上使用。相反,让他们与签名整数一起工作,我决定人工限制大小来假装我有一个无符号整数。换句话说,我可以使用I16来假装是U8(无符号的8位整数),因为范围[0,255]舒适地适合在范围内[-32768,32767]。

假设我不使用每个I16的前八位,然后每个I16结束八位,8 * 4 = 32. Yay!让我们开始在数学上工作。

大多数系统使用称为两个补充的技术存储负数。如果避开无符号的3位整数,就像从000到111的计算一样,整数GOFROM 0到7.但是对于存储为两个补充的符号3位整数,为从000到111,整数从0到0到0 3,然后缠绕到-4,然后达到-1。这将可用范围分成两半,但允许您扭转负数!

添加和减法“只是工作”,带有正面和负数不得不做任何特别的事情。例如,2(010)+ -4(100)= 110,其对应于-2。

很容易看出数字是否定的:检查最有效位(最远的左侧)是1。

要在2的补充中实施否定,请翻遍所有位并添加一个。这看起来像:

PUB FN DO_NEG(& mut self){//翻转每个整数。我们需要和0xff以播放所有// 5位的位,因为那些位始终需要为0. self.parts [0] =!self.parts [0]& 0xFF; self.parts [1] =!self.parts [1]& 0xFF; self.parts [2] =!self.parts [2]& 0xFF; self.parts [3] =!self.parts [3]& 0xff; //添加一个,处理携带。 self.parts [0] + = 1;如果self.parts [0]> = 256 {self.parts [0] - = 256; self.parts [1] + = 1;如果self.parts [1]> = 256 {self.parts [1] - = 256; self.parts [2] + = 1;如果self.parts [2]> = 256 {self.parts [2] - = 256; self.parts [3] + = 1; }如果self.parts [3]> = 256 {self.parts [3] - = 256; }}

您可能会注意到我将其实现为一个突变函数,而不是使用负面结果的新INT32。这是一个刻意的选择额外拨款。通过重用相同的结构,我不需要为每个数学操作进行Allocatemore内存,我将使用这些。 Thismight在铁锈中捏捏,但它是杰克的绝对救星。 Ialso没有使用Rust的操作员重载,因为杰克没有那种。

如上所述,在两个符合世界中,加法和减法相当容易。像学校数学,如果需要,您会添加每个组件并将其携带到更高的块。

PUB FN DO_ADD(& mut self,其他:& int32){self.parts [0] + =其他.parts [0]; self.parts [1] + =其他.parts [1]; self.parts [2] + =其他.parts [2]; self.parts [3] + =其他.parts [3];如果self.parts [0]> = 256 {self.parts [0] - = 256; self.parts [1] + = 1;如果self.parts [1]> = 256 {self.parts [1] - = 256; self.parts [2] + = 1;如果self.parts [2]> = 256 {self.parts [2] - = 256; self.parts [3] + = 1; }如果self.parts [3]> = 256 {self.parts [3] - = 256; }}

请记住,部分[0]是最小的块和部分[3]是对比的重要意义。这也是I16中的额外空间的一个例子,允许我在[0,255]范围内添加两个数字,而无需担心i16overflow。

这是它变得棘手的地方。乘法可以像学校的那样完成,在那里你将每对数字乘以每对数字并相应地偏移。

我从这篇文章中拍摄了算法,并达到它来生锈(和后来的千斤顶)。但是,我遇到了两个问题。

首先,我仅为简单起见,仅适应算法的无符号版本。我需要处理迹象。幸运的是,这不是太糟糕:

如果两侧是否定的(即,如果a为负^ b是负= true),则否定结果。

其次,我在实现期间再次遇到整数大小尺寸问题。原始实现乘以无符号短裤(或ruct中的U16),并使临时无符号int(U32)存储来存储结果。这是必要的,因为乘法是扩展操作。例如,如果您是任何两个两位数的数字,结果将是最多4位数。在策略的方式中,要适当地适合U16 * U16的结果,您需要一个U32。

这对我们来说是坏消息,因为我们只有一个尺寸,I16。你可能会思考我们可以做到这项工作,因为我们秘密地使用下半部分,以上是初步的。但是,即使这不起作用。

由于我们只使用I16的下半部分,因此它具有一系列[0,255]。当我们乘以255乘255时会发生什么?我们得到65,025,哪个不适合I16(范围[-32768,32767])。悲伤的时间!这使得感觉使[0,255]范围由类型U8表示,如果您是两种U8S,则获得一个U16,其中具有范围[0,65535]。所有Wehave都是I16(范围[-32768,32767]),它不适合:(这是一个在杰克的无符号整数类型的情况会真的有帮助,但我们无人。

我们如何解决这个问题?答案是再次细分。在执行Phonitultiplication算法之前,我们将四个8位块的数组扩展为84位块。每个8位块都分为两个4位块,一个带有窃听的四个位,四个位较高。

让self_parts_expanded:[i16; 8] = [self_parts [0]& 0x0f,arith_rightsshift(self_parts [0],4),self_parts [1]& 0x0f,arith_rightsshift(self_parts [1],4),self_parts [2]& 0x0f,arith_rightshift(self_parts [2],4),self_parts [3]& 0x0f,arith_rightsshift(self_parts [3],4),];

现在,我们可以在这些4位块上使用我们的乘法算法,知道unsigned 4位乘法将导致U8,这适合我们的I16。 Huzzah!

//每个I16的前12位应该是空的,所以像一个" U4" //我们'重新模拟只有i16的千斤顶。 fn u4_array_mul_u4_array(U:& [I16; 8],V:& [I16; 8]) - > [I16; 16] {让mut w = [0 i16; 16]对于j在0 .. 8 {让mut携带= 0;设vj = v [j];因为我在0 .. 8 {//执行签名的16位数学,永远不会溢出//,因为我们只将U4S放入其中! U4 * U4 = U8,其//适合I16。我们可以' t确实使用了U8S,因为U8 * U8 = // U16和U16在I16中拟合。让T = U [i] * vj + w [i + j] +携带; //较大的次数进入结果;顶部//半成为下一轮的携带。 w [i + j] = t& 0x0f;携带= arith_rightssswort(t,4); w [j + 8] =携带; } w}

结果是在4位块中生成的,因此我们需要组合每对反向块一个8位块,我不会在此显示。

请注意,我使用功能来执行右移,而不是Rust'snative>&gt ;.这是因为千斤顶没有正确的移位运算符,因此SOI必须实现它。

//杰克没有有这些,所以我们需要重新实现它们。 FN Arith_rightShift(X:I16,N:I16) - > I16 {让mut r = x;对于_在0..n {让Divided = R / 2; //负数远离0,而不是0. r =如果r< 0&& divided * 2!= r {divided - 1} else {divided}} r}

司类似于乘法,但有点复杂。只是喜欢的,我从这个页面翻译了代码。天文师GIST也类似于等级学校的长师,在那里您将引用数量逐个生成,乘以除数中的商数,并从股息中乘坐。

我们使用相同的技术在执行ThealGorithm之前取代绝对值,然后在恰好输入零件时否定结果。

至于溢出问题,我们需要将每个8位块分成两个4个击球次,就像乘法一样。不同之处在于乘法是:8块块* 8块=> 16块块,而部门执行einverse:16块块/ 8块= 8块块。我不会在此显示代码,但ITJust意味着输入和输出阵列大小是不同的。

有很多方法来计算方形根,大多数都涉及一些您运行的融合公式,直到您具有所需的准确性。 Chosethe Babylonian方法超值。计算X的平方根:

平均猜测和X /猜测,仔细估计平方根,并更新猜测。

所有这些都是用整数数学完成的,所以它会找到最接近的整数squareroot。

PUB FN DO_SQRT(& mut self){如果self.is_negative(){恐慌!();如果self.is_zero(){返回;让mut猜测= int32 :: from(5);对于_在0 .. 20 {让mut inv = * self; inv.do_div(&猜测);猜测.DO_ADD(& inv);猜测.do_div(& int32 :: from(2)); self.parts =猜测.Parts; }

为简单起见,我猜测每次猜测5并跑20次迭代。我们稍后会谈论优化。

我们完成了我们的INT32实施!您可以在此处看到最终代码以及单元测试。现在,我们将继续实施固定频率。

如上所述,通过将十进制乘以刻度因子并存储在尺度因子和存储数量来涉及使用整数TOEscode算法涉及十进制。

有时,使用像Q15.17这样的语法来描述该方案。 Q15.17Format是32位(15 + 17),并使用15位进行小数点数据,17位用于小数点数据。例如,旧的游戏Quake使用了Q16.16,因为它的所有代码都是因为当时的计算机还没有快速浮动点数学。

如果Q16.16足够好的地震,那么它可能对我来说足够好,所以我会做同样的事情。这意味着我将使用16位的后十进制尖端数据,这意味着比例因子将是2 ^ 16 = 65536,我将能够从-32768到32767到32767的小数点编号,精确为1/65536 ,或0.00001525878。

由于该方案,它恰好的是Int32Parts阵列中的前两个I16将用于预分级分数数据,以及底部两个ForPost-Decimal Data。

现在,我们将讨论如何使用我们的内容何种内容来实现固定点数学。

要从常规int构造一个数字,我们需要将其包装在int32中,然后通过比例因子乘以它:

我在int32上写了一个方便的函数来处理剩下的班次。 Shiftingleft 2字节与乘以2 ^ 16,比例因子相同。

添加和减法相当容易,因为添加或减去具有相同比例因子的两个数字在其背衬INT32上执行相同的比例因子。

PUB FN DO_ADD(& mut self,其他:& number){self。 0.do_add(&其他0); PUB FN DO_SUB(& mut self,其他:&数字){self。 0.do_sub(&其他0); }

要乘以两个固定数字,我们可以将其支持INT32乘以。但是,由此产生的数字将额外乘以比例因子,因此我们之后需要除以比例因子来取消它。

这造成了严重的问题。如果我们在我们的固定数学中尝试执行2 * 2,则禁止int32s将执行131072 * 131072,这将溢出INT32的最大值2,147,483,647(2 ^ 31 - 1)。

我们可以考虑在乘以之前的规模因子分开,但是扔掉了一个多样性的所有次数尖端表现的严重缺点。

为了解决这个问题,我意识到乘法函数的输出占八位的块,但我们只能存储底部四个块的ofeight bits,所以我们通常忽略前四个块。

但是,如果乘法函数取“向右移位”参数,则可以从较高的块中读取并更改忽略的信息。 forexample,如果right_shive_bytes为0,则从结果块0-3读取。 IFIT的1,它从结果块1-4读取。这种技术允许我们融合并划分在一起,以避免在其间失去信息。 TheDivide是通过右移完成的,因为右移将部分转移1个等同于除以256(8比特)。

方便地,由于我们选择了与我们的阵列方案对齐的比例,我们可以将2作为右键传递2,并获得所需的结果。

该部门类似于乘法,但我们事先需要乘以ScaleFactor以避免分母中的额外比例因子。

但就像乘法一样,我们可以用划分捆绑乘法,避免丢失精度或溢出。结果是do_left_shift_bytes_div:

就像乘法和划分一样,方形根部涉及执行interningling Int32操作并纠正比例因子。在这种情况下,采取了平方

......