Kohi 学习笔记

教程

Kohi Game Engine

clang 报错 LINK : fatal error LNK1104: 无法打开文件“…\bin\engine.dll”

clang %cFilenames% %compilerFlags% -o ../bin/%assembly%.dll %defines% %includeFlags% %linkerFlags%

原因是不存在这个 bin 文件夹,需要自己创建

内存分配记录

在内存分配的基础上再套一层壳,在这层壳中,主要是得到分配的内存数 size,更新统计信息

这样,我们的游戏引擎自己就能知道,在引擎中,引擎给资源分配了多少内存

甚至不单单是资源,比如不单单是传统的那种纹理资源,模型资源等,还有自己创建的数据结构,在自己的数据结构中分配内存调用的是这个分配内存的壳的话,就也能记录到自己的数据结构使用的内存了

比如动态数据,比如字符串,比如自己定义的结构体

字符串所占的空间也是需要计算的……emmm感觉我很容易忽略

他之后还写了,string 的格式化输出其实也是得到一个新的 string 其实也是要记录这个新的 string 的内存分配

我觉得是因为他是习惯用 C 写,所以他就习惯“定义结构体,分配一块结构体的内存给某个指针”这样的操作吧

如果是 cpp 的话,或许适合用 RAII 的风格,把引擎记录内存分配这种事写到构造函数和析构函数里?

更进一步,如果我们给不同的资源用 enum 区分,那么我们除了可以统计总内存,还可以统计不同类型的资源格子分配了多少内存

void* kallocate(u64 size, memory_tag tag) {
    if (tag == MEMORY_TAG_UNKNOWN) {
        KWARN("kallocate called using MEMORY_TAG_UNKNOWN. Re-class this allocation.");
    }

    stats.total_allocated += size;
    stats.tagged_allocations[tag] += size;

    // TODO: Memory alignment
    void* block = platform_allocate(size, FALSE);
    platform_zero_memory(block, size);
    return block;
}

void kfree(void* block, u64 size, memory_tag tag) {
    if (tag == MEMORY_TAG_UNKNOWN) {
        KWARN("kfree called using MEMORY_TAG_UNKNOWN. Re-class this allocation.");
    }

    stats.total_allocated -= size;
    stats.tagged_allocations[tag] -= size;

    // TODO: Memory alignment
    platform_free(block, FALSE);
}

使用 enum 最后一个位置来记录 enum 的数量

他有一个习惯是用 enum 最后一个位置来记录 enum 的数量

就很巧妙其实

enum {
    DARRAY_CAPACITY,
    DARRAY_LENGTH,
    DARRAY_STRIDE,
    DARRAY_FIELD_LENGTH
};

比如这里 DARRAY_FIELD_LENGTH 是 3,表示 darray 的属性有三个

C 实现泛型的方法

C 实现泛型的方法就是使用 void* 指针……

要存一个东西到动态数据,就用 sizeof(type) 先算好这个类型的单位大小,然后按照这个单位大小创建动态数组

#define darray_create(type) \
    _darray_create(DARRAY_DEFAULT_CAPACITY, sizeof(type))

void* _darray_create(u64 length, u64 stride) {
    u64 header_size = DARRAY_FIELD_LENGTH * sizeof(u64);
    u64 array_size = length * stride;
    u64* new_array = kallocate(header_size + array_size, MEMORY_TAG_DARRAY);
    ...
}

从数组中取值用 void* 接收

void _darray_pop(void* array, void* dest) {

事件系统

预先定义好若干个 enum,每一个 enum 表示一个事件

然后定义一个事件 enum 长度的数组,这个数组的第 i 个元素是第 i 个事件 enum 对应的一个“订阅事件”类型的动态数组

“订阅事件”类型中包含两个成员,一个是订阅者,一个是回调函数

fire 事件时,传入事件的 enum,在事件数组中找到对应 enum 的“订阅事件”类型的动态数组

比如传入的是 EVENT_CODE_APPLICATION_QUIT 这个 enum,它对应一个 registered_event 的数组

typedef struct registered_event {
    void* listener;
    PFN_on_event callback;
} registered_event;

而每一个 registered_event 里面有订阅者也有回调函数,直接对所有的 registered_event 都调用回调函数就好了

他这里还做了一个处理,如果某一个回调函数返回真,那么就提前终止遍历,不在调用这个数组之后的 registered_event

也就是事件被激发的时候,只要有一个监听者成功处理事件了,就不再通知其他监听者了

这个我觉得……可能这个规则会有点死板吧,如果想要做能够通知所有监听者的,或者是想要按照一个层级顺序来先后通知的话,可能要改……?

union 定义数据结构的用法

学了 c 之后还是第一次看到 union 的用法,原来是这么用的

就是 union 中的所有数据成员共享同一个内存,那么我可以先用一个随便叫什么名字的变量先把最大内存定好了,然后我可以用一个没有名字的 struct 来确定这个 union 的各个部分数据叫什么名字,如果比如一开始我用了一个 f32 的两个长度的数组,然后 struct 里面是 x 和 y,那么我就可以 vec2.x vec2.y 这样用,而 struct 里面我再利用 union 共享内存的性质,相当于给同一个内存起别名,这样,对于 vec2 的第一个 f32,我可以叫作 x 也可以叫作 r 也可以叫作 s 也可以叫作 u,可以照顾数学,颜色,纹素,UV 等习惯

typedef union vec2_u {
    // An array of x, y
    f32 elements[2];
    struct {
        union {
            // The first element.
            f32 x, r, s, u;
        };
        union {
            // The second element.
            f32 y, g, t, v;
        };
    };
} vec2;

线性内存分配器

关于分配内存,我们之前只是单纯对各个平台的 malloc 统一封装成一个 platform_allocate

现在我们还可以再对 platform_allocate 做一层封装成 linear_allocator,为了干什么呢?创建 linear_allocator 的时候,我们先给 linear_allocator 分配一定的内存,然后我们需要把这个内存分配给某一个东西的时候,我们就从 linear_allocator 中取出一部分内存出来

也就是说,原来 allocate 是直接调用 malloc,现在我要 allocate 一个内存,我先是创建一个 linear_allocator,再从 linear_allocator 中拿内存,那么现在其实是 linear_allocator 创建时 malloc,调用 linear_allocator 的 allocate 接口实际上是,计算一下已分内存 + 要分内存是否 < 本 linear_allocator 在创建时分配的总内存,如果小于,那么就返回这段内存的指针

感觉这样就是,方便一次性申请一大块内存,提供给一些小的物件使用吧

避免万向节死锁的方法

避免万向节死锁,除了根据应用场景安排旋转顺序、使用四元数计算旋转之外,还可以钳制中间的那个旋转度数到 89 度,这样就不会进入万向节死锁了

渲染器框架

前端:Meshes, Textures, Materials, Render Passes, Render Graph

后端:Vulkan, GPU Upload

运行流程:初始化,准备资源,设置 GPU 状态,渲染,检查是否继续渲染,继续渲染则回到准备资源这一步,不继续渲染就关闭渲染器

首先,渲染器前端需要有初始化,结束,开始帧渲染,结束帧渲染,尺寸改变这些函数接口

后端具体实现这些函数,例如初始化就是创建 vulkan instance

然后我们需要在创建 vulkan 的时候添加验证层

然后我们需要添加 vulkan 窗口,也就是 surface,vulkan 窗口是与平台无关的,所以我们要传入每个平台自己的窗口句柄,所以我们直接传入 win32 窗口句柄就好了

如果是自己写 win32 窗口的话自然存了窗口句柄,如果是用 glfw 完成的窗口创建,那么就从 glfw 拿

然后要创建物理设备

从物理设备这里,可以获取到 queue family 信息,我们需要选择使用哪些 queue family

我们还可以获得物理设备的 properties,features

用 properties 可以判断 GPU 的类型

用 features 可以判断物理设备是否支持一些特性,例如 samplerAnisotropy

我们可以自定义一个 requirement 结构体,用来指定物理设备需要满足哪些条件

然后我们创建一个函数来检查物理设备是否满足这个 requirement

requirement 里面有对 graphics present transfer 队列的要求,对 sampler_anisotropy,discrete_gpu 的要求,还有对拓展的要求 device_extension

验证完了,已经获得了各个 queue 的 index,那么就可以设置 queue 的 create info,最终可以设置逻辑设备的 create info,创建逻辑设备

逻辑设备创建好了,就可以从逻辑设备中获得各个 VkQueue,可以根据逻辑设备创建 command pool

然后查询是否支持交换链,如果支持,那么创建交换链

创建交换链需要知道 surface format,需要获取支持的 present mode,默认的是 FIFO;需要知道范围 extent,需要设置交换链里面最小的 image 数量,需要设置 queue family 等

frame in flight 的个数一般的教程会说是 2 个,但是如果我们只有一个 image 可用的话,那这里就会出现错误,所以我们应该是从交换链获取了 maxImageCount 之后,frame in flight 的个数是 maxImageCount - 1

(这里确实有点没懂为什么 - 1,既然要追求正确,那么为什么不干脆就是 maxImageCount?难道是为了让渲染的延迟减少?但是油管主又没有说他在渲染延迟这方面的考虑)

创建交换链之后,交换链自己带了若干个 image,我们需要知道有多少个 image,然后创建对应数量的 imageView,然后我们还需要创建一个深度 image 和 imageView 留待后用

创建 image 可以抽象成一个函数,首先创建 image,然后获得 image 对 memory 的 requirement,然后根据这个 requirement 获得 memory 的 index,然后根据这个 index 来 allocate memory,然后 bind memory,然后创建对应的 image view

创建 image view 需要指定 image,指定 mipmap 等级等

交换链的接口需要有创建,销毁,还有重新创建,重新创建也就是先销毁再创建

交换链里面还需要获取 image 和呈现。获取 image 要用 vkAcquireNextImageKHR,除了需要等待的信号量 image_available_semaphore 之外,还需要等待一个 fence 用来渲染和逻辑同步。呈现 image 要用 vkQueuePresentKHR,只用传入一个渲染呈现完成时的信号量 render_complete_semaphore

然后创建 render pass,目前只有一个 subpass,为了这个 subpass 要创建 attachment reference,要创建 subpass 的 dependency。为了整个 render pass 要创建 Attachment Description,颜色附件一个 Description,深度附件一个 Description

创建好 render pass 之后,对 render pass 的 begin 和 end 做封装

创建相当于交换链中的 image 数量的数量的 command buffer。要从 command pool 创建,我们在创建逻辑设备之后创建 command pool。

创建了的 command buffer 需要 allocate

对 vkBeginCommandBuffer vkEndCommandBuffer 也要封装

如果是 command 只使用一次的情况,这种就是在 begin 的时候 allocate,在 end 的时候 free

然后是要创建 frame buffer,对于交换链中的每一个 image 都要生成一个 framebuffer,每一个 framebuffer 都需要指定 attachment view,那么交换链的第 i 个 image 对应的第 i 个 image view 和唯一的那个深度 view 就是第 i 个 framebuffer 需要的 attachments

然后我们需要指定 frame in flight 的数量,其实就是为了方便 CPU 和 GPU 同时工作

每一个 frame in flight 都对应一套信号量 image_available_semaphore queue_complete_semaphore 栅栏 in_flight_fence

在没有开始渲染之前,刚创建 fence 的时候,fence 应该是处于已经被 signal 的状态。因为我们在渲染循环中一开始要等待这个 fence 被 signal 表示上一帧已经渲染结束了,而在一开始,还没开始渲染时,我们就开始等这个 fence,那一开始 fence 应该是”已经渲染完“,不然我们在初始时会一直卡住

fence 的创建,销毁,wait,reset 都可以封装

在交换链的 present 接口中,present 的末尾更换当前 frame

然后是读 spv 创建 shader module

然后创建 graphics pipeline,其中可能要考虑一下设置哪些为动态可变的 VkDynamicState

因为 graphics pipeline 每一个 stage 使用的 shader 是不可变的,所以他这里在 shader 的创建函数中,创建了 shader module 之后就创建 graphics pipeline,并且他把 graphics pipeline 算作是 shader 的一部分

然后创建 vertex buffer 和 index buffer,他这里是一次性创建了 1M 的量

buffer 抽象成一个类,有创建,销毁,copy,resize 等接口

copy buffer 的具体实现就是创建一个临时的 command buffer, single use 的,要创建 VkBufferCopy 对象,执行 vkCmdCopyBuffer

buffer 的 load data 就是 vkMapMemoryvk memcpy UnmapMemory 的封装

从 CPU 上传数据到 local device 的 buffer,需要先创建一个临时的 staging buffer,然后先 load data 到 staging buffer,staging buffer 再 copy 到 local 的 buffer

这个时候,绑定 shader,绑定 vertex buffer, index buffer, vkCmdDrawIndexed 就可以画三角形了

然后是创建 UBO。首先要创建 descriptor set layout,而创建 descriptor set layout 需要设置 descriptor set layout binding

创建好了 descriptor set layout,在创建 graphics pipeline 的时候就可以放到 create info 里面

然后可以利用之前的 buffer 的 create 接口创建 UBO

然后创建 descriptor pool

然后要创建 descriptor set

然后他这个对 descriptor set 的 allocate 分配的是三个一样的,之前设置的,descriptor set layout

我看别的教程,也是元素都是相同的之前设置的 descriptor set layout,但是数量是 frame in flight 的数量

更新 UBO 的话,就是先要把数据写到 UBO 的 buffer 中,然后要创建一个 VkWriteDescriptorSet 调用 vkUpdateDescriptorSets

Push Constant 就是包装 vkCmdPushConstants。在 pipeline 创建的时候要在 create info 里面标明 push constant 的信息

创建纹理时,先创建一个 staging buffer,把数据传进来,然后创建一个 image。先拿一个 single use 的 command buffer,我们需要先把这个 image 的 layout 从 VK_IMAGE_LAYOUT_UNDEFINED 改为 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,方便我们接下来将数据从 buffer 传送到 image,然后我们使用 vkCmdCopyBufferToImage 将数据从 buffer 传送到 image,然后我们再将 image 的 layout 从 VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL 改到 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,最后我们要为这个 image 创建一个 sampler

完成布局转换的具体就是对 vkCmdPipelineBarrier 的包装。因为可能有多种 src layout 和 dst layout 的情况,所以这个包装内需要有一个部分来判断这个情况

然后是为纹理 image 创建 descriptor set layout

依然是先创建 descriptor set layout 然后创建 descriptor pool

然后需要修改 graphics pipeline 需要的 VkVertexInputAttributeDescription

之后他这个 update descriptor set 的操作让我看了好久

在每帧绘制的开始,需要知道从交换链中拿出的 image 的 index

持续存在的 command buffer, image in flight, frame buffer, descriptor set 都是每个 image 对应一个的

更新的话,是要对当前的 descriptor set 更新

他这里还对 UBO 做了区分,有全局的 UBO 和 物体的 UBO,全局的 UBO 用一个 global_descriptor_sets,物体的 UBO 和 sampler 用 object 的 descriptor_sets

所以在初始化的时候也要对所有的 object 创建 descriptor_sets

加载纹理使用 stb_image

他对材质还自己做了引用计数

他把材质、纹理都记录到了一个全局的 set 里面

对于多 render pass 的情况,每个 render pass 的详细配置是不一样的,比如 attachment description 这里,对于第一个 render pass,initialLayout 是 VK_IMAGE_LAYOUT_UNDEFINED,对于之后的 render pass,image 的 layout 都已经转换成 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 了,所以之后就都是 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;finalLayout 也类似,对于最后一个 render pass 颜色附件需要输出,所以设为 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,其他 render pass 就设为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL

如果要把创建 render pass 封装到一个函数里面的话,就要考虑这些事情

比如这里为了考虑这两个事情,设置了两个 bool 来表示,当前 render pass 是不是第一个,是不是最后一个

感觉这样反而比较麻烦?

他把 vkAllocateDescriptorSets 封装为 shader_acquire_resources

然后它的 material 里面记录物体的 id,基本上这个 id 是用来计算 buffer 中的数据的偏移量的

每一个 shader 有自己的若干个实例 instances,他定为 MAX_INSTANCE_COUNT,可以用 material 中的 id 索引

每一个 shader 也有自己的一个一大块的 UBO,大小是单个 instance 需要的 uniform buffer 的大小 * MAX_INSTANCE_COUNT,可以用 material 中的 id 索引

shader_apply_material 就是从 shader 的实例数组 instances 中根据 material 中的 id 取出对应的元素,进而可以取到 VkDescriptorSet,然后根据 material 中的 id 还可以取到 shader 的一大块的 UBO 之中的对应 id 的那一块

之后改了个 bug 是先 vkUpdateDescriptorSets 然后再 vkCmdBindDescriptorSets,好像没有人特意强调过这一点……?

之后是逐渐看不懂了……

你可能感兴趣的:(GameEngineDev,学习,笔记)