再见C ++,你好C

2021-06-26 09:27:26

本系列关于我的玩具渲染器的第1部分涵盖了最基本的设计决策。多年来,我已经写了许多渲染者,并且很长一段时间他们的复杂性一直在增长。这一次,我采取了相反的路线。我想最大化实现至关重要功能的代码的分数,而不是浪费我在融合的基础架构上的时间。我编写的代码(不包括着色器)在345 kB时有7575行。不完全是4K介绍,但比以前使用的任何其他实时渲染器小得多。它需要加利福。一个秒来编译和链接和启动也很快。

我想澄清一件事:我倡导这里的设计决定是针对由一个人开发的研究渲染器制作的。我并没有争辩说,同样的设计应该受到大型商业项目的青睐。但是,如果您为大型商业产品开发新技术,则可能最好有一个小型测试用床,例如我在这里描述的那个。和一些想法,例如,我加载场景的方式可能会鼓舞人力更大的项目。

长期汇编时间是对生产力的巨大损害。编译和链接具有大量依赖性的大C ++项目,有些模板魔法可以轻松花费几分钟。我曾经有一个C ++项目,只有300行代码。不幸的是,它还具有标题,以标题为中心的库eIgen作为依赖性,因此编译时立即升至半分钟(对于单个* .cpp文件)。

这对编程工作流程是什么意思?当您实现一大块新功能时,由于需要重新编译,您可以忽略测试它的小位。一旦完成,您将编译。也许在汇编中间的某些时候,你将抛出错误。一旦修复,您再次编译,可能会达到遇到运行时错误的点。也许你必须编译一个调试版本以了解那些。在另一个之后,你挤上了一个琐碎的错误,最终就会工作。如果您在该过程中编译并链接了十次,并且平均花了一分钟,则浪费了十分钟的等待您的编译器。

我坚信这次总是浪费。时间框架很短,以至于它不是切换到另一个任务的Wortwhile。人们培养了不同的闲暇习惯,例如经常检查新闻道路。在最糟糕的情况下,这意味着您在编译完成并丢失更多时间时,您不会注意到。

当然,人们了解这个问题,解决方案有很多尝试。虽然,所有这些都使编程更受限制和程序以某种方式更加复杂。例如,存在pimpl成语,但添加了样板代码,使代码更清晰,只缩短了某些类型的更改的编译时间。模块是改善编译时的另一种尝试,但收益似乎是中等的。您可以在问题上抛出硬件,但是编译和链接并行化的可能性是有限的,因此无法让您所有的地方。

减半编译时间很好,但将它们减少到第二个是更好的。只想到你没有等待编译器的某些情况。热插拔着色器,例如,在shadertoy,是一个很好的例子。这种环境鼓励实验和测试,提高生产率并消除很多挫折,特别是在调试期间。 Printf调试在具有长编译时的设置中,如果编译和启动快速,则可以非常方便。 python与scipy stact是另一个很好的例子。当然Python比C ++执行更慢,但大多数时候我的Python脚本在类似的C ++程序之前运行就会完成编译。

受到这种经验的启发,发动机开发人员有时会试图让您尽可能多地完成,而无需重新编译。热长着色器重新加载是一个积极的例子。节点图,插件系统或单独脚本语言的集成有点可疑。它们可能是非常强大的,但努力让他们与发动机的所有部分交互是大量的。源代码是一种令人难以置信的强大和富有表现力的方式来完成各种任务。如果您为相同目的投入了很多努力,这对于相同的目的也可能不那么方便,因此由于编译时,您正在跳过箍。

我一直在使用C ++很长一段时间,一年左右,我已经热衷于此。我深入进入面向对象编程,模板魔法,STL和提升的兔孔。我习惯了长期编译时段和样板代码。然后三个经历逐渐震撼了我的信仰进入C ++:热着着色器重新加载,Python和Corona(不是你所想到的那个)。此时前两个应该是显而易见的。对您的代码进行更改,并查看结果后稍后释放。此外,Python比C ++更精简。作为示例,以下代码片段分别从NUMPY和EIGEN中的4×4矩阵中提取3个顶部行。

然后我做了一些离线渲染,我使用了由我的同事Johannes Schudeiss开发的研究渲染器Corona(渲染器的名字在大流行前已经尴尬,考虑到有一个具有相同名称的商业渲染器)。这就是我如何欣赏C. Corona的优雅是一个适度的大项目,但它在两秒钟内汇编。并且代码库具有令人愉快的小电库板代码。其中C ++在相同的概念上略有不同(例如,unique_ptr,shared_ptr和原始指针),C为您提供一种方法,并且那个具有方便的光谱符号(如float *)。内存的块是一种普遍和自然的抽象,即C接口可以灵活,长寿命(STB是一个很好的例子)。

坚持矩阵数学的例子,它令人困惑C代码通常比C ++代码更紧凑且可读。矩阵声明将是Float Matrix [4] [4];常绿矩阵和四元数常见问题有许多很好的示例,如何在C ++中采用相同的方法,但是您并不真正使用C ++。来自上面的例子可以简单:

面向对象的编程使方法和全局函数成为方法。在某些情况下,我已经以简单的原因更喜欢这个设计:当一个函数与多个类的实例相互作用时,它通常会在您放置的地方稍微任意。它可能是任何一个课程或全球的成员。如果全局是默认值,则不需要猜测。我的结构具有全局创建和破坏功能。如果它们是memset为零,则它们是“默认构造”。如果在初始化期间出现问题,请创建调用销毁以进行清理。这种方法会引发很少的样板代码。特别是,我赢了' t错过了写作和吸气器。

但当然,与C ++相比c的杀手功能是短期的次数。我不羡慕C ++编纂者的开发人员。 C ++标准使他们的工作变得困难,有时会导致效率妥协。 C易于编译和通过设计链接。我可以相信其他较新的语言如Rust或D比现代C ++更令人愉快。但是,它们似乎在编译时表现得类似。

再现性在研究中是重要的。其他人应该能够轻松运行我的代码并获得相同的结果。第一部分经常受到依赖关系的阻碍。在这方面,C和C ++并不容易。确保代码易于编译的最佳方法,链接和运行是将依赖项限制为裸露的最小值。

那么最少的是什么?要使用GPU,我需要一个图形API。 vulkan是最广泛支持的那个是自然的选择。我不打算编写自己的扩展装载机,我还需要一些操作系统基础(例如窗口创建和输入/输出)。 GLFW涵盖了这些需求。亲爱的Imgui非常有助于快速原型设计。它并不完全简约,而是它表现得自身。您应该只将其源文件添加到项目中。它是用C ++编写的,所以我需要一些C / C ++ Interop,但这是无痛的。最后,我使用stb_image_write.h将屏幕截图写为png,jpg或hdr。

所有的。我用源代码发布,我将亲爱的imgui,glfw和stb发货。 GLFW被编译为单独的项目,其他一切都是我渲染器项目的一部分。因此,链接有很少的事情可能会出错。 Vulkan SDK是唯一必须单独安装的唯一事情,但这很容易且不可避免,因为它是特定于平台的。我使用cmake,但它没有多大。

当我开始使用新的图形API(如vulkan)时,我的旧Inthince将是在我需要的所有功能周围编写包装。但这真的是什么呢?好吧,包装:

Vulkan规范在图形开发人员中相对众所周知。它肯定比一些私人小包装者更熟悉,我会放在它的顶部。

包装器的重复主题是他们尝试将图形API的广泛功能限制为经常使用的位。虽然这可以使某些任务有点方便,但它使其他任务是不可能的。在图形研究中,您常常寻求最新和最大的功能(例如,在我的渲染器中的雷查询)。有时你需要一些相当晦涩的功能。如果您依靠包装器,您首先在这些模糊的功能周围编写包装器(与您自己的不熟悉的界面即兴),然后使用它们。直接使用它们通过图形API简单更高效。

与任何软件一样,API和图形驱动程序具有错误。但是,该软件广泛使用,广泛测试和由许多付费专业人士进行广泛的测试和开发。错误的速度相当低。任何包装器都会继承所有这些错误,并在其自己的内容中添加一些,最有可能以更高的速度。

在诸如OpenGL之类的状态API中,很难确保在每个绘制呼叫之前确保API处于正确状态。包装者可以帮助。但vulkan全部进入管道对象,使国家单片。不再需要这些功能。

我对此没有用。我很满意vulkan的C接口,以及我使用它们的方式(见下文)。

确实,包装器可以实现这一目标,但它永远不会容易。通常必须针对所有目标API仔细测试在包装纸上的程序。我希望上下一个水平。具有一致的场景描述,可以由具有不同API的渲染器呈现,或者使用Moltenvk这样的东西将完整的图形API映射到不同的图形API上。

底线是我想直接使用vulkan。 vulkan structs和函数在我的大多数渲染器的大多数部分使用。从API独立代码没有墙绘制API特定代码。 vulkan并不意味着换任何东西。我宁愿重写整件事。

当然,从头划痕与vulkan的写作软件是很多工作,我都不想每一次都这样做。我在渲染我的第一个三角形之前写了1750行代码。尽可能,我的渲染器由可重复使用的小块组成。它们并不包装,因为它们直接使用vulkan structs和句柄,但它们捆绑在一起常用的一些功能。如果这就是你想要的,很好。如果没有,请直接调用vulkan函数或写入另一个实用程序。

这是一个看起来的一个例子。创建几个缓冲区时,通常还要为它们分配和绑定内存。因此,我具有通过单个内存分配维护任意数量的缓冲区,并提供对某些元数据的方便访问:

//将缓冲句柄与offset //结合起来!和size键入dem struct buffer_s {//!缓冲箱vkbuffer缓冲区; //绑定内存//的偏移量!分配以字节vkdeviceSize偏移; //此缓冲区的大小没有//!填充vkdeviceize大小; buffer_t; //所有共享A //的缓冲区列表!单内存分配类型键入型struct buffers_s {//!持有缓冲区数量uint32_t buffer_count; // buffer_count aruge buffer_t *缓冲区; //服务//的内存分配!所有缓冲区vkdeviceMemory存储器; //整个//的字节的大小!内存分配vkdeviceSize大小;}缓冲区_t; / *!根据给定规范创建一个或多个缓冲区,对所有的所有数据执行单个内存分配并绑定它。 \ param缓冲输出对象。使用destroy_buffers()释放它。 \ param设备使用的设备。 \ param buffer_infos一个要创建的每个缓冲区的规范(总共缓冲区)。 \ param buffer_count要创建的缓冲区数。 \ param memory_properties要为内存分配实施的内存标志。 vkmemoryheapfleagbits的组合。 \返回成功。* / int create_buffers(buffers_t * buffers,const device_t *设备,const vkbuffercreateinfo * buffer_infos,uint32_t buffer_count,vkmemorypropertyflags memory_properties); / *!销毁给定对象中的所有缓冲区,将设备内存分配释放,销毁数组,零柄和零的对象。* / void destroy_buffers(buffers_t * buffers,const device_t *设备);

Crucally,Create_Buffers()期望一系列VkbufferCreateInfo,而不是一些包装器模仿该结构。无论旗帜是多么异形标志,您要为缓冲区创建使用,您可以使用它们。

在使用这个函数时,我从Christoph Schied学到的技巧就发挥了。 C中的结构初始化允许您处理Python中的关键字参数等vulkan结构。最近,C ++也可以做到这一点。您未提及的所有内容都会获得零初始化和vutkan,精心设计,以便为零提供合理的默认值。例如,此代码为分层随机数表创建临时缓冲区:

vkbuffercreateinfo buffer_info = {.stype = vk_structure_info_buffer_create_info,.size = sizeof(uint16_t)* cell_count,.usage = vk_buffer_usage_transfer_src_bit}; buffers_t staging; if(create_buffers(& stage,device,& buffer_info,1,vk_memory_property_host_visible_bit | vk_memory_property_host_coherent_bit)){printf("无法为噪声创建%llu字节暂存缓冲区。\ n",buffer_info.size) ;返回1;}

我争辩说,前两条线是样板,但其他一切都表达了一些有意义的东西,我需要什么样的缓冲区。这就是它在大多数时候如何运作。

此示例还说明了我的内存管理。基本策略是每用目的具有一个内存分配,例如,一个用于顶点数据,一个用于纹理,一个用于分层随机数。这可能不是典范,但在这样一个小项目中运作良好。我的渲染器在第一帧之前共进行21个内存分配,包括临时缓冲区。

我凭借此代码的可重用性做出了积极的经验。例如,我曾经想要一个无头应用程序,可以在GPU上优化一些蓝色噪声点集。虽然这种用例与渲染器写的是完全不同的,但它没有花费大量时间并完美无缺。与我当前设置有点烦人的一件事是多传递的渲染。但这主要是因为我此时只有三次通过,所以我没有打扰这类东西的很多支持代码。

在我之前的渲染者之一中,我对一系列自动化系统感到骄傲,它一直跟踪依赖图,并且会自动弄清楚如果有任何改变,可以自动弄清楚应重新初始化的顺序。它是一个花哨面向对象的系统,其中从某些公共基类和方法调用中继承了定义的依赖性。我的新玩具渲染器具有在两行代码中实现的相同功能。我认为,就“保持简单”而言,这部分是最好的例子。

我有一个完整的布尔值的struct application_updates_t,以跟踪由于用户输入而需要更改的内容。然后有一个函数来执行这些更新,这会为应用程序启动而调用它,并且每帧一次:

/ *!重复需要执行以实现给定更新的所有初始化过程。 \返回0成功。* / int update_application(application_t * app,const application_updates_t * update_in);

在此功能中,我首先标记直接受此更新影响的对象,并且需要重新创建。

vkbool32 swapchain = update.recreate_swapchain; vkbool32噪声= update.startup | update.regenerate_noise; vkbool32 ltc_table = update.startup; vkbool32场景= update.startup | update.reload_scene; vkbool32 render_targets = update.startup; vkbool32 render_pass = update.startup; vkbool32 content_buction_buffers = update.startup | update.update_light_count | update.change_shading;

但当然,还有依赖性来解释。对象在创建期间引用了其他对象,因此如果其他对象重新创建,它们也必须重新创建。例如,Swapchain的娱乐通常意味着分辨率已经改变,因此也必须重新创建渲染目标。这是用于由依赖图层处理的部分。现在它由一个循环处理。

uint32_t max_dependency_path_length = 16; for(Uint32_t i = 0; i!= max_dependency_path_length; ++ i){render_targets | = swapchain; Render_pass | = Swapchain | Render_targets; constant_buffers | = swapchain;}

如果您想成为花哨的话,您可以调用依赖图。 |操作员定义边缘,循环实现宽度但精简和正确的方式。我非常喜欢这个代码的原因是,几乎所有它都定义了使用紧凑符号的图形。只有用于FOR-LOOP的两条线都可以实现图形遍历的图形。在面向对象的设置中,您可以过载|运算符获取相同的语法。但是,没有人会知道该代码在不查找操作员的定义的情况下。应用AN或BOOLEAN是不言自明的。

一旦所有依赖项都已传播,需要销毁的对象以相反的顺序被销毁:

vkdevicewaitidle(app-> device.device); if(constant_buffers)destroy_constant_buffers(& app-> constant_buffers,& app->设备); if(render_pass)destroy_render_pass(& app-> render_pass,& app->设备); if(render_targets)destroy_render_targets(& app-> render_targets,& app->设备); if(场景)destroy_scene(& app->场景,& app->设备); if(ltc_table)destroy_ltc_table(& app-> ltc_table,& app->设备); if(噪声)destroy_noise_table(& app-> lest_table,& app->设备);

然后,如有必要,Swapchain会在不重新创建底层窗口的情况下调整大小。最后,所有其他对象都会重新创建。它保证首先重新创建所有这些对象。此步骤利用第一个错误中止的紧凑码的短路评估。

if((噪声& load_noise_table(& app->& app-& app-> device-> device,get_default_noise_resolution(app-> render_settings.noise_type),app-> render_settings.noise_type))||(ltc_table && load_ltc_table(& app-> ltc_table,& app->设备,"数据/ ggx_ltc_fit&#34 ;,51))||(场景& load_scene(& app- >场景,& app->设备,app-> scene_specification.file_path,app-> scee_specification.texture_path,vk_true))

......