Ascii Ray Franing介绍

2021-03-08 02:20:23

为下周准备我的电脑图形考试,我决定接受事物的低级方面的游览:光线游行旋转球体的ASCII图形!

对象渲染是常用于游戏和可视化的技术相似:API如OpenGL,允许您绘制从基元像三角形块等原语组成的对象。将原语投射到2D中,进行深度测试,以确保另一个人背后的物体是看不见的,最后,它们以像素上的像素上彩色。这样,您将在屏幕上“将”3D对象“括号”。

图像排序呈现采用不同的方法,但是:您不会在对象之后填充屏幕对象,但像素之后的像素。因此,而不是查看三角形并弄清楚在屏幕上绘制的地方,你拍摄屏幕的一个像素,并找出它可能的颜色。随着光源发射的光线击中物体,其中一些被反射到观察者的眼睛或相机中。由于发射了很多光线但从未实际击中相机,则追溯到追溯它们更有效:从相机发送“光线”,确定它们在何处击中对象,然后看看光源的路径是否为闭塞。这种技术可以允许更逼真的图形,与现代技术相似,如NVIDIA RTX GPU,该技术采用了对象阶和图像级渲染的混合。

虽然这些技术可用于产生高质量的图像,但在本文中,我们将尝试将由ASCII字符组成的图像渲染到终端中。但是,它可以很容易地适应输出图像文件。

在本文中,我们将专注于图像级渲染,特别是射线。其中光线跟踪由使用“固定”算法计算与光线交叉点的基元组成的场景,光线游行通过表面距离功能隐式表示场景。它将空间中的一个点映射到场景中最近对象的距离的下限 - 类似于雷达,它告诉我们我们可以进一步走得更远而不击中任何东西。

图1说明了光线游行的过程。我们从Ray R开始,评估该位置的表面距离功能(SDF)并获得红色周长。我们继续走进光线方向,直到我们与周边相交并再次评估SDF。在这种情况下,我们没有击中任何对象,并且光线将继续直到永恒(或浮点溢出,在这种情况下)。

图2显示了如果我们靠近表面:周长会变得更小,更小,直到它击中某个阈值,我们认为它与表面碰撞。

如果我们在屏幕上的每个像素射出光线(我们的终端中的ASCII字符),我们应该能够渲染3D场景。让我们跳入代码!

我将使用C的C ++混合使用C,在Linux机器上运行。您将在此页面底部找到完整的源。

第一种方法将进行实际的光线行程:发出光线并跟随它直到击中对象。然后它将阴影称为曲面位置作为参数,这将计算该点的发光程度。 SDF是射线遍历所需的表面距离功能。

首先,我们需要一个数据结构来存储3D空间中的点。 vec3是它的名称,三维向量短:

到目前为止漂亮的标准。现在我们添加了一些运算符,因此我们可以添加和减去它们和方法来确定它们的长度:

浮动长度(){返回Sqrt(x * x + y * y + z * z); void normalize(){float vectorLength = length(); x = x / vectorLength; y = y / vectorLength; z = z / vectorLength; } struct vec3运算符*(float fac){struct vec3 r; r。 x = x * fac; r。 y = y * fac; r。 z = z * fac;返回r; } struct vec3运算符+(struct vec3其他){struct vec3 r; r。 x = x +其他。 X ; r。 y = y +其他。 y; r。 z = z +其他。 Z;返回r; } struct vec3运算符 - (struct vec3其他){struct vec3 r; r。 x = x - 其他。 X ; r。 y = y - 其他。 y; r。 z = z - 其他。 Z;返回r; }};

标准化可用于保留载体的方向,并确保其长度为1。

作为显示,我们使用存储每个像素的值的帧缓冲区。像素可以是字符串像素中定义的七个ASCII字符之一。

#include< math.h> #include< unistd.h> #include< time.h> #define宽度80#定义高度40静态char framebuffer [高度*宽度]; Const int npixels = 7; const char * pixels =" 。:+ | 0#" ;

接下来,我们将实现实际横梁的函数:它迭代每个XY坐标并射击光线。相机将处于位置(0,0,-3),因此这就是我们的光线将开始的位置。我们将屏幕定位在相机前面有点一点点,并从相机原产地发送射线“通过”像素目标(参见图3)。我们必须纠正y坐标一点,因为屏幕通常不是完美的正方形。

void raymarch(){for(int y = 0; y<高度; y ++){for(int x = 0; x<宽度; x ++){struct vec3 pos = {0.0,0.0,0.0, - 3.0 }; struct vec3目标= {x /(浮点)宽度 - 0.5 f,(y /(浮点)高度 - 0.5 f)*(高度/(浮点)宽)* 1.5 f, - 1.5 f}; struct vec3 ray = target - pos;射线 。正常化();

光线必须归一化:稍后,我们希望将其乘以SDF来获取新位置POS。如果光线的长度不会是1,我们可能会最终超过物体内部的过冲和降落。

初始化射线后,我们可以在其方向开始。我们在这里雇用另一个循环,第3个嵌套for-loop!您可能开始了解为什么雷行军被视为计算昂贵。我们还将像素值PXL存储返回的光线并用第一个像素值(这是空格)初始化。 DIST将持有SDF的价值并告诉我们对象的谎言有多远。 Max是我们将三月有多远的上限。

char pxl =像素[0]; Float Dist; float max = 9999.0 f; for(int i = 0; i< 15000; i ++){if(pos x)> max || fabs(pos。y)> max || fabs(pos。z)> max ) 休息 ;

Fabs是浮点数的绝对值。如您所见,如果光线超过任一维度超过最大值,则我们将其删除并颜色为像素暗。否则,我们需要更新位置:我们评估表面距离功能并将远距离进入我们的光线方向:

dist = sdf(pos); if(dist< 1e-6){pxl = shade(pos);休息 ; pos = pos + ray * dist; } //结束(i)

如果距离小于0.000001,我们将假设我们已经击中了表面并呼叫阴影功能以相应地彩色该像素。在这种情况下,我们也可以退出内部循环,因为我们已经击中了一个对象。否则,for循环将继续3月,直到光线耗尽界限,或者击中15000的最大迭代计数。

剩余的所有这些都是将像素值写入帧缓冲区,并从函数返回:

FrameBuffer [Y * width + x] = PXL; } //结束(x)} // for(y)} //结束raymarch()

这总结了我们计划的一部分的光线!很短,考虑到它实际上是从头开始的3D场景。

即使Raymarch()函数完成,我们也没有谈论我们将要渲染的对象。对于初学者来说,我们将保持简单:一个半径为0.2的位置(0,0,0)的简单球就足够了。

表面距离功能必须告诉我们到表面上最接近点的距离。它将在物体外部的点,物体内部的点数和表面上大约零的阳性。我们如何计算给定位置POS的SDF?简单:我们从位置中减去球体中心并计算该矢量的长度。如果我们从该值中减去半径r,我们将获得位置到表面的位置。这正是SDF功能的实施方式:

Float SDF(STRUCT VEC3 POS){STRUCT VEC3 CENTER = {0.0,0.0,0.0};返回(pos-center)。长度() - 0.2; }

为了快速测试,我们将使用可想而知的最简单的着色功能:它只是用“黑暗”字符(#)颜色所有曲面点:

此部分有点镗孔:它包含用于打印帧缓冲区并清除屏幕的代码。随意跳过它。

//终端清除序列const char * cls_seq =" \ e [1; 1h \ e [2j" ; void cls(){写(0,cls_seq,10); void printfb(){char * fb = framebuffer; char nl =' \ n' ; cls(); for(int y = 0; y<高度; y ++){写(1,fb,宽度);写(1,& nl,1); FB + =宽度; } int main(){for(int i = 0; i<宽度*高度; i ++)framebuffer [i] =' ' ;而(true){raymarch(); printfb();睡眠(1); }}

在主函数中,我们清除帧缓冲区,然后输入光线的无限循环并绘制帧缓冲区。

$ g ++ raymarcher1.cpp -lm -o raymarcher $ ./ raymarcher ##### ############################### ################################################## ##### ###########################################

罗哈,看不见!它实际上是屏幕上的球体。它看起来有点无聊,但这很快就会改变。

我们要添加的第一件事是一个计时器,吐出正确时刻的框架并为球体进行动画。为此,我们需要将以下内容添加到我们的文件开头:

虽然(true){double last_frame = t; Raymarch(); printfb();框架++; do {struct timespec时间; clock_gettime(clock_realtime,&时间); t =时间。 tv_sec +时间。 TV_NSEC * 1E-9; }而((t - last_frame)< 1.0 / 60.0); }

在重绘屏幕之前,最后一位将等待1/60的一秒。

在写阴影功能时,我们有点拍摄:它不包括任何照明,并为球体上的每个点分配相同的亮度。要有点香味,我们将介绍一个光源:

Char Shade(struct vec3 pos){struct vec3 l = {50.0 * sin(t),20.0,50.0 * cos(t)};湖正常化();

接下来,我们将计算所谓的正常情况:它是定义表面方向的矢量。它与我们表面的切向平面恰好正交。在传统的渲染技术中(如基于对象的排序),正常是我们呈现的几何的一部分。在我们的案例中,通过SDF,我们采取了一个不同的路径:如果普通点远离我们的表面点,这意味着它将是SDF最快增长的方向。微积分告诉我们,这个方向被称为梯度,可以通过导出该功能来确定。这就是我们要做的事情:

float dt = 1e-6; float clust_val = sdf(pos); struct vec3 x = {pos。 x + dt,pos。 y,pos。 z}; float dx = sdf(x) - current_val; struct vec3 y = {pos。 x,pos。 y + dt,pos。 z}; float dy = sdf(y) - current_val; struct vec3 z = {pos。 x,pos。 y,pos。 z + dt}; Float Dz = SDF(Z) - Current_Val; struct vec3 n; // n正常n。 x =(dx - pos。x)/ dt; ñ。 Y =(Dy-POS。Y)/ DT; ñ。 Z =(DZ - POS。Z)/ DT;

接下来,我们必须处理正常计算失败的情况。在这种情况下,我们中止了阴影过程,只需返回一个黑暗像素:

如果它当然没有正常化,正常不会是正常的!现在我们已经获得了正常和光矢量,我们可以做基本的Phong Sading:在七十年代,聪明的科学家们发现了一种简单的方法,可以做出非常好的亮度。他们提出了以下公式:

灯光= k环境·C环境+ K漫射·C漫射·(L·N)+ k镜·(R L·V)N·C镜片

环境照明,这是不源于一个特定光源的光,但只是漂浮在房间周围。其强度由系数k环境称为C,其颜色是C环境,其是具有3个组分的RGB载体。

漫射照明,其是来自光源的光,当撞击物体表面时散射。 (L·N)是光矢量和正常矢量之间的点产品,并且由于两者具有长度1,它是两者之间的角度的余弦。由于余弦靠近角度较小,因此当光源直接闪耀到表面上方的漫射照明比它在“地平线上方之上即可。

镜面照明,这是由反射产生的亮点。 R L是反射矢量。

我们将忽略环境和镜面照明,专注于漫射照明。由于我们没有任何彩色光线,我们可以安全地忽略C漫射组件。所以剩下的就是术语(L·n) - 我们只需要计算光矢量和正常矢量之间的点产品:

float diffuse = l。 x * n。 x + l。 y * n。 y + l。 z * n。 Z; Diffuse =(Diffuse + 1.0)/ 2.0 * npixels;

由于点产品返回余弦,这在-1到1的范围内,我们添加1并将其分两个,因此它在0到1之间。然后我们乘以不同像素值的数量。在下一点中,我们将以最接近的亮度级别返回像素:

就是这样。编译它,运行它,看看一点点着色可以改善你的图形,即使在终端中运行!

在仅需128行代码中,我们能够实现具有基本Phong-Shading和ASCII输出的射线游行。我们能够应用一些多维微积分(正常计算)和几何体(SDF)。很多东西都可以调整和扩展,特别是因为SDF提供了表达场景的非常紧凑的方式。 Inigo Quilez组装了一页不同SDF的综合页面。