目录
前言
总体印象
一般流程
创建窗口
创建设备
准备资源
1.VBO,IBO
2.图像
3.资源转储
4.采样器
5.着色器
6.渲染管线
绘制
插曲
1.忘记初始化
2.关于数学库
SDL3_image-toc" style="margin-left:40px;">3.SDL3_image
结语
前言
几天前冲浪才得知SDL有了一下代,而且还支持GPU编程,赶紧扒下来玩。
这里主要是个人探索过程中问题的记录和分享,敬请斧正。
不是教程,不阐述概念,只分享想法。
SDL版本:3.1.6 开发语言:C++
总体印象
SDL3把GPU编程化繁为简,不仅写起来直观,还支持多种图形API,只用写一次代码,就能用多种API驱动,非常推荐给喜欢写渲染的童鞋。
老实说SDL3GPU真没什么明显缺点,要真有那可能就是底层图形API的细节被隐藏了,有些API的处理不直观,但是SDL3毕竟开源,花点时间定位一下就可以看到细节了。
可能是因为我的第一个图形API就是Vulkan,才让我对它所有复杂度无限包容,才感觉SDL3GPU如此简单?
一般流程
和很多图形API的流程一样(虽然我只用过Vulkan),SDL3GPU只是对原生API的抽象和总结方案,提供了更系统和安全的方式管理图形渲染。
SDL3GPU的官方介绍有对使用流程的一般性概括,这里我就简单的用自己的语言描述出来。
SDL3/CategoryGPU - SDL Wikihttps://wiki.libsdl.org/SDL3/CategoryGPU SDL3 GPU 官方介绍。
TheSpydog/SDL_gpu_exampleshttps://github.com/TheSpydog/SDL_gpu_examples SDL3 GPU 的示范,里面的例子都很好。
创建窗口
记得之前写Vulkan的时候,连窗口都是用SDL的,现在终于不用分家了。
SDL自己可以创建各种款式的窗口,所以要记得指定一下创建窗口的flag,比如我用Vulkan那就创建个支持Vulkan渲染的窗口。
除非你用不到窗口。
创建设备
创建设备时需要指定一下用到的着色器格式,我是Vulkan就用SPIR-V这样子。然后可以显式的指定一下想要使用的API的名称,也可以指定要不要开DEBUG模式。
非常的人性化,我用Vulkan它就帮我把验证层打开,也不用填一堆Vulkan的什么layer,什么extension之类的。
还要记得给设备“声明”(Claim)一下我们刚刚创建好的窗口。
值得一提的时,Claim完后,SDL就帮我们创建好了交换链Swapchain了,同时你可以为通过其他API给设置交换链的属性,看起来SDL把交换链及其属性,衍生物(它对应的缓冲区等)封装成了一个叫Swapchain Composition的玩意。
准备资源
资源就是那些渲染过程中反复利用的东西。SDL如是说道。
确实啊,如果我们的需求很简单,比如只是简单地画一个三角形,那么其实到这里整个流程已经差不多一半了。但是如果是复杂的渲染场景,需要的资源又会很多很多,而不同的资源又有不同的创建,管理方式。所以这里我就以2D渲染为例描述一下可能会用到的资源(其实是因为还没研究透):
1.VBO,IBO
就是那些顶点缓冲区啊,顶点索引缓冲区之类的。SDL把它们都抽象成了同一个类型,Buffer,创建它们只需要指定一下用法和大小就好了,非常方便好吧,还要注意这些Buffer实际上是在GPU里创建的,我们拿到的只是一个持有它引用的句柄罢了,我在这里提一嘴是因为后面还会用到。
然后我们还需要“编造”自己的顶点,顶点索引等数据类型,比如这里因为是2D渲染,我只用了两个顶点坐标,较小的索引(其实可以更小)数据类型大小:
typedef uint16_t Index; // 定义16位无符号整型为“索引”
typedef glm::vec2 Vector2;
typedef glm::vec3 Vector3;
typedef glm::vec4 Vector4;
typedef glm::mat4 Matrix4x4;struct Vertex2D // 声明一个2D渲染用到的顶点类型
{Vector2 pos;Vector2 uv;
};
因为2D渲染绝大多数对象就是一张图,四个点,六个索引。 这里的数据类型大小与后面的很多操作高度相关,所以尽可能“编造”得合理一些好点。因为用的Vulkan,直接把老熟人glm库扒过来为我所用。
2.图像
即Texture,老话说Texture和Buffer本质没什么不同,只是使用场景不同。但是大家还是把它们分开来抽象,口是心非(doge)。
Texture的创建需要比Buffer多点东西,格式啊,层级啊之类,毕竟使用场景多而杂。然后再次提醒,这也是GPU里的东西,我们拿到的只是一个句柄。
在SDL3GPU里一般Texture的创建,包括Buffer,是用来给实际的资源数据“预留空间”的。这些所谓的实际资源数据需要我们自己从CPU传给GPU,这就会用到TransferBuffer。
在
3.资源转储
还记得在Vulkan,创建资源非常繁琐,首先还是一样(或者说SDL3GPU跟Vulkan一样)在GPU中创建buffer,指明它是GPU专用的;然后再创建一个staging buffer,指明它是CPU,GPU互通的(这样才能映射到CPU),然后把它映射到CPU中;再从CPU端把数据copy到映射过来的那片内存中;然后结束映射,最好再用指令把GPU里的staging buffer Copy 到同样在GPU里的buffer,还要记得销毁staging buffer。
上面这段流程,我们要在SDL里再做一遍。当然了,是以更简单的方式,上面的staging buffer就是SDL3GPU里的TransferBuffer。
直接说过程确实太抽象了,直接上代码:
void TransferBuffers()
{// 这里的unit是自定义的一个渲染单元的实例// 只需要知道这里是在计算用到所用空间的大小size_t verticesSize = sizeof(Vertex2D) * unit->vertices.size();size_t indicesSize = sizeof(Index) * unit->indices.size();// 这里开始创建TransferBuffer// usage 的 upload 指明用作上传数据到GPU// 这里因为顶点跟索引都是buffer,可以放在一起 uploadSDL_GPUTransferBufferCreateInfo transferBufferCreateInfo{};transferBufferCreateInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD;transferBufferCreateInfo.size = verticesSize + indicesSize;SDL_GPUTransferBuffer* transferBuffer =SDL_CreateGPUTransferBuffer(device, &transferBufferCreateInfo);// 宏定义的空检查NULLCheck(transferBuffer);// 开始拷贝数据// 先要把TransferBuffer映射过来void* vertexDataMapped = SDL_MapGPUTransferBuffer(device, transferBuffer, false);// 然后复制顶点数据memcpy(vertexDataMapped, unit->vertices.data(), verticesSize);// 这里是复制索引数据,因为前面有一段内存留给顶点了,所以指针要偏移verticeSizememcpy(static_cast<char*>(vertexDataMapped) + verticesSize,unit->indices.data(), indicesSize);// 最后记得解除映射SDL_UnmapGPUTransferBuffer(device, transferBuffer);// 同样地,映射Texture到另一个TransferBuffer,// 因为Texture跟Buffer不一样,只好用另外的TransBuffersize_t textureSize = unit->GetImageSize();transferBufferCreateInfo.size = textureSize;SDL_GPUTransferBuffer* textureTransferBuffer =SDL_CreateGPUTransferBuffer(device, &transferBufferCreateInfo);NULLCheck(textureTransferBuffer);void* textureDataMapped = SDL_MapGPUTransferBuffer(device, textureTransferBuffer, false);memcpy(textureDataMapped, unit->GetImageData(), textureSize);SDL_UnmapGPUTransferBuffer(device, textureTransferBuffer);// 所有映射都结束后,我们需要从所有TransferBuffer中把数据再复制到最终的// Buffer和Texture中,这就需要用到CommandBuffer。SDL_GPUCommandBuffer* uploadCmd = SDL_AcquireGPUCommandBuffer(device);NULLCheck(uploadCmd);// 开启一个CopyPassSDL_GPUCopyPass* copyPass = SDL_BeginGPUCopyPass(uploadCmd);std::vector<SDL_GPUTransferBufferLocation> bufferLocations(2);bufferLocations[0].transfer_buffer = transferBuffer;bufferLocations[0].offset = 0;bufferLocations[1].transfer_buffer = transferBuffer;bufferLocations[1].offset = verticesSize; // 跳过顶点数据std::vector<SDL_GPUBufferRegion> bufferRegions(2);bufferRegions[0].buffer = vertexBuffer;bufferRegions[0].offset = 0;bufferRegions[0].size = verticesSize;bufferRegions[1].buffer = indexBuffer;bufferRegions[1].offset = 0;bufferRegions[1].size = indicesSize;SDL_UploadToGPUBuffer(copyPass, &bufferLocations[0], &bufferRegions[0], false);SDL_UploadToGPUBuffer(copyPass, &bufferLocations[1], &bufferRegions[1], false);SDL_GPUTextureTransferInfo textureTransferInfo{};textureTransferInfo.transfer_buffer = textureTransferBuffer;textureTransferInfo.offset = 0;SDL_GPUTextureRegion textureRegion{};textureRegion.texture = texture;textureRegion.w = unit->w;textureRegion.h = unit->h;textureRegion.d = 1;SDL_UploadToGPUTexture(copyPass, &textureTransferInfo, &textureRegion, false);SDL_EndGPUCopyPass(copyPass);// 结束CopyPass 并提交cmdBufferSDL_SubmitGPUCommandBuffer(uploadCmd);// 释放TransferBuffersSDL_ReleaseGPUTransferBuffer(device, transferBuffer);SDL_ReleaseGPUTransferBuffer(device, textureTransferBuffer);
}
我定义了一个渲染单元RenderUnit, 用来封装用到的资源,这里不需要了解它的内部。
如果你有耐心看完的话会发现其实流程很清晰,和Vulkan差不了多少。
这里重点在于CommandBuffer,和Vulkan中一样(或者说和所有图形API)一样,它是用来记录各种各样的命令的。
但是和Vulkan中不同,SDL3GPU似乎对CommandBuffer“要求很严格”,控制欲很强,一个cmdBuffer在Submit之后就不能在使用它了。
Commands only begin execution on the GPU once SDL_SubmitGPUCommandBuffer is called. Once the command buffer is submitted, it is no longer valid to use it.
以前写Vulkan的时候就喜欢重复使用cmdBuffer,现在在SDL里不需要了,各有各的优点吧。
所以SDL里的cmdBuffer我们用到的时候再创建,不用全局化。
4.采样器
既然我们用到了Texture,怎么能少了采样器呢Sampler呢(这两个大概率是一对)。
采样器的创建也是填一个结构体的事,重点是在这里可以选择不同的采样方式,UV映射方式,各向异性过滤等等配置。
对不同的游戏渲染来说尤为重要。
5.着色器
我们的Shader,这不废话嘛,渲染哪有不用着色器的?软光栅除外。
我特意按照合理的顺序介绍资源,因为Shader是集成前面几位的东西,而且在创建时也会用到前面几位。
Shader首先得需要指定代码,而且是编译后的那种,所以我们不仅要写Shader,还要编译Shader。这里我就直接略过这方面的过程了,我是直接“遵循”Vulkan Tutorial的例子来做的。
还要注意指明使用的阶段,使用的文件格式,入口函数,使用到的各种各样资源的具体数量,所以我建议是在前面的资源都准备好后再创建Shader。
Shader创建很简单,填个createInfo就好了。但是!因为SDL3接管了底层驱动API,所以在编写和创建Shader对象时千万要注意按照SDL官方说明来规范。
Shader resource bindings must be authored to follow a particular order depending on the shader format.
For SPIR-V shaders, use the following resource sets:
For vertex shaders:
- 0: Sampled textures, followed by storage textures, followed by storage buffers
- 1: Uniform buffers
For fragment shaders:
- 2: Sampled textures, followed by storage textures, followed by storage buffers
- 3: Uniform buffers
比如这里说资源绑定要符合一定顺序,我只截取了有关SPIR-V的内容,这里的0,1,2,3序号在Vulkan中指的是描述符集DescriptorSet的序号0123,在别的API可能指的是什么槽Slot啊什么的。换句话说我们的SPIR-V Shader只能在0号描述符集绑这些,在1号描述符集绑定那些。
比如下面的顶点着色器,根据要求我们的Uniform Object只能绑在描述符集1号,故layout里的set要为1。
#version 450layout(set = 1, binding = 0) uniform MVP
{mat4 model;mat4 view;mat4 proj;
} mvp;layout(location = 0) in vec2 pos;
layout(location = 1) in vec2 uv;layout(location = 0) out vec2 frag_uv;void main()
{gl_Position = mvp.proj * mvp.view * mvp.model * vec4(pos, 0.0, 1.0);frag_uv = uv;
}
只有这样SDL才能帮我们处理着色器的创建,以及它背后的一堆屁事。我其实已经把描述符集什么的忘得差不多了,不过就是因为实在搞不懂这里的binding在Vulkan中到底binding的是什么,查了半天源码终于查到是四个Set了,唉。
6.渲染管线
该来的还是会来的,渲染管线,直接在代码里面描述吧:
void CreatePipeline()
{std::vector<SDL_GPUVertexBufferDescription> vertexBufferDescs(1);vertexBufferDescs[0].input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX;vertexBufferDescs[0].instance_step_rate = 0;vertexBufferDescs[0].pitch = sizeof(Vertex2D);vertexBufferDescs[0].slot = 0;std::vector<SDL_GPUVertexAttribute> vertexAttributes(2);// PositionvertexAttributes[0].buffer_slot = 0;vertexAttributes[0].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2;vertexAttributes[0].location = 0;vertexAttributes[0].offset = 0;// UVvertexAttributes[1].buffer_slot = 0;vertexAttributes[1].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2;vertexAttributes[1].location = 1;vertexAttributes[1].offset = sizeof(float) * 2;// 顶点输入阶段需要对顶点进行详细描述,用到上面那堆结构体SDL_GPUVertexInputState vertexInputSatate{};vertexInputSatate.num_vertex_buffers = vertexBufferDescs.size();vertexInputSatate.vertex_buffer_descriptions = vertexBufferDescs.data();vertexInputSatate.num_vertex_attributes = vertexAttributes.size();vertexInputSatate.vertex_attributes = vertexAttributes.data();// 颜色混合阶段在SDL3GPU中与每个渲染目标直接相关SDL_GPUColorTargetBlendState blendState{};// 开启混合blendState.enable_blend = true;blendState.alpha_blend_op = SDL_GPU_BLENDOP_ADD;blendState.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA;blendState.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;blendState.color_blend_op = SDL_GPU_BLENDOP_ADD;blendState.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA;blendState.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;// 禁用颜色写入掩码 等价于 写入全部颜色通道blendState.enable_color_write_mask = false;// 拿到SDL早就为我们创建好的交换链的格式SDL_GPUTextureFormat format = SDL_GetGPUSwapchainTextureFormat(device, window);// 只是一个Bool检查SuccessCheck(SDL_GPUTextureSupportsFormat(device, format, SDL_GPU_TEXTURETYPE_2D, SDL_GPU_TEXTUREUSAGE_COLOR_TARGET));// 关于渲染目标的描述,我们这里的目标就是交换链上的图像std::vector<SDL_GPUColorTargetDescription> colorTargetDescs(1);colorTargetDescs[0].format = format;colorTargetDescs[0].blend_state = blendState;SDL_GPUGraphicsPipelineTargetInfo targetInfo{};targetInfo.num_color_targets = colorTargetDescs.size();targetInfo.color_target_descriptions = colorTargetDescs.data();// 光栅化阶段,这几个参数尽量不要事先指定SDL_GPURasterizerState rasterizerState{};rasterizerState.fill_mode = SDL_GPU_FILLMODE_FILL;rasterizerState.cull_mode = SDL_GPU_CULLMODE_BACK;rasterizerState.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE;// 最后把它们集成到一个整体// 没有用到的阶段可以直接省略,这点SDL3GPU做的很好SDL_GPUGraphicsPipelineCreateInfo pipelineCreateInfo{};pipelineCreateInfo.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;pipelineCreateInfo.vertex_shader = vertexShader;pipelineCreateInfo.fragment_shader = fragmentShader;pipelineCreateInfo.rasterizer_state = rasterizerState;pipelineCreateInfo.target_info = targetInfo;pipelineCreateInfo.vertex_input_state = vertexInputSatate;pipeline = SDL_CreateGPUGraphicsPipeline(device, &pipelineCreateInfo);NULLCheck(pipeline);Print("Pipeline ready!");
}
因为这里是2D渲染,深度和模板测试等等我没有用到。
所以在初始调试的时候其实可以没必要一下子参数拉满,可以根据需求一步一步来调整。
我觉得这里与Vulkan最大的不同就是把颜色混合从一个单独的阶段对应到每个不同的渲染目标上面去了。 当然这只是代码结构上的不同,本质上没有什么区别。
反正就像所有烦人的图形API一样照着一个一个结构填呗。重点是要理解,其实可以从一些文字上看出来,渲染管线描述的只是一个静态过程,而实际的参与过程的东西需要我们的RenderPass指定。
绘制
DrawCall。其实我不知道到底叫它DrawCall合不合适,索性就这么叫吧。
作为最后一步,重要性自然没得说:
void Draw(double deltaTime)
{SDL_GPUCommandBuffer* commandBuffer = SDL_AcquireGPUCommandBuffer(device);NULLCheck(commandBuffer);SDL_GPUTexture* swapChainTexture;SuccessCheck(SDL_AcquireGPUSwapchainTexture(commandBuffer, window, &swapChainTexture, nullptr, nullptr));// Must do null check hereif (swapChainTexture != nullptr){std::vector<SDL_GPUColorTargetInfo> colorTargetInfos(1);colorTargetInfos[0].texture = swapChainTexture;colorTargetInfos[0].load_op = SDL_GPU_LOADOP_CLEAR;colorTargetInfos[0].clear_color = { 0.2f, 0.2f, 0.2f, 1.0f };SDL_GPURenderPass* renderPass =SDL_BeginGPURenderPass(commandBuffer, colorTargetInfos.data(), colorTargetInfos.size(), nullptr);SDL_BindGPUGraphicsPipeline(renderPass, pipeline);std::vector<SDL_GPUBufferBinding> bufferBinding(2);bufferBinding[0].buffer = vertexBuffer;bufferBinding[1].buffer = indexBuffer;SDL_BindGPUVertexBuffers(renderPass, 0, &bufferBinding[0], 1);SDL_BindGPUIndexBuffer(renderPass, &bufferBinding[1], SDL_GPU_INDEXELEMENTSIZE_16BIT);SDL_GPUTextureSamplerBinding samplerBinding{};samplerBinding.texture = texture;samplerBinding.sampler = sampler;SDL_BindGPUFragmentSamplers(renderPass, 0, &samplerBinding, 1);mvp = CalculateMVP(*unit, *camera);SDL_PushGPUVertexUniformData(commandBuffer, 0, (void*)&mvp, sizeof(MVP));SDL_DrawGPUIndexedPrimitives(renderPass, unit->indices.size(), 1, 0, 0, 0);SDL_EndGPURenderPass(renderPass);}SDL_SubmitGPUCommandBuffer(commandBuffer);
}
由于RenderPass本质上就是一堆指令加状态,需要绑定到某个CommandBuffer。而前面我们说到SDL3GPU里的CmdBuffer是不可复用的(应该说是不可自己实现复用),所以RenderPass和CmdBuffer一样也是临时创建出来的,跟着CmdBuffer同生死。
它们两个还非常有意思地形成了像xml里的嵌套形式,一个acquire之后要submit,一个start之后要end。所以能不能用xml写渲染?!
RenderPass里面除了绑定些基本的,还有个很有意思的,在Vulkan应该叫做“推送常量”(有可能不是一个概念),因为对于Vulkan而言,SDL事先约定好了顶点阶段的Uniform放在哪个Set中,在这里就是Set = 1,所以我们只用指定该Uniform的binding就好了。
在Push之前已经计算好了MVP矩阵,然后直接Push即可,对,就这样简单(Vulkan患者的迟疑)。
还要些设置Viewport之类的功能就不对赘述了。
插曲
1.忘记初始化
我是重度CSharper,我们是不会出现局部变量自己没有初始化的情况的,所以......
如果你声明了一个局部结构体之类的什么东西,请确保它已经初始化了或者你有信息把它的参数全部填对填满。否则里面的指针就会到处乱跑,值就会到处乱飞。
2.关于数学库
因为比较熟悉Vulkan,所以开发时用到的全部都是OpenGL全家桶。所以数学库就选用了GLM。
数学库之所以重要是因为有很多地方需要用到矩阵,包括但不限于MVP。
在实际游戏开发中,通常需要指定所谓摄像机的参数,这个东西究其本质就是View+Proj矩阵的实体,通过它的一些参数可以很方便地更新View+Proj矩阵:
下面是一个不太好的摄像机定义:
class Camera
{
public:enum ProjectionType{ORTHOGRAPHIC,PERSPECTIVE};Vector3 position;Vector3 front;Vector3 up;ProjectionType projectionType;float fov;float aspectRatio;float zNear;float zFar;float size;Camera(){position = glm::vec3(0.0f, 0.0f, 0.0f);front = glm::vec3(0.0f, 0.0f, -1.0f);up = glm::vec3(0.0f, 1.0f, 0.0f);fov = 45.0f;// 虽然这里初始化为1.0f// 但还是需要手动指定摄像机的横纵比,不然会出现图形的变形aspectRatio = 1.0f;zNear = 0.1f;zFar = 1000.0f;size = 100.0f;projectionType = ORTHOGRAPHIC;}glm::mat4 GetViewMatrix() const{return glm::lookAt(position, front, up);}glm::mat4 GetProjectionMatrix() const{if (projectionType == ORTHOGRAPHIC){ float H = 2 * size;float W = (aspectRatio * H);return glm::ortho(-W / 2, W / 2, -size, size, zNear, zFar);}if (projectionType == PERSPECTIVE){return glm::perspective(glm::radians(fov), aspectRatio, zNear, zFar);}return glm::mat4(1.0f);}
};
初次之外,还有诸如模型空间的变换,单位的位移,父级子级之间的相对运动等等,都需要好的数学库支持。
SDL3_image">3.SDL3_image
这是属于SDL的拓展库,可以弥补SDL不支持PNG等图像加载的等缺点。
用在2D渲染上应该是绰绰有余了。
结语
也许你依然觉得SDL3GPU很复杂,那你之前可能是写OpenGL的,因为这点代码对于Vulkan来说连渲染管线都不一定创建得完(doge)。
非常好API,使我的Vulkan旋转!