Scene -> VulkanScene
+ camera = VulkanEnvironment
Scene
核心:读文件的时候只保留offset信息等,传递一个文件流ifstream给Scene.data, 相当于把Scene作为实际文件的轻量化视图,实际需要数据的时候再直接按照offset从文件流中读取.
struct EnvironmentInit {
EnvironmentInit(const std::vector<InstanceProperties> &instances,
const std::vector<LightProperties> &lights,
uint32_t num_meshes);
std::vector<std::vector<glm::mat4x3>> transforms;
std::vector<std::vector<uint32_t>> materials;
std::vector<std::pair<uint32_t, uint32_t>> indexMap;
std::vector<std::vector<uint32_t>> reverseIDMap;
std::vector<LightProperties> lights;
std::vector<uint32_t> lightIDs;
std::vector<uint32_t> lightReverseIDs;
};
- Mesh和instance: 单个Mesh可能对应若干个instance, instance对应唯一的Mesh
- Instance transform Material: 一个instance对应一个transform和material(如上,用同样格式的数据结构存储,即一个二维矩阵, 代表第i的mesh对应的第j个instance)
- indexMap: 第i个数据代表着全局id为i的instance,目前位于mesh矩阵的a行b列(用来从上面找数据)
- reverseIDMap: 行数为mesh个数, i行的所有数据代表第i个mesh对应的所有instance的id集合
e.g.
- 假设有 3 个 mesh(0,1,2),共 5 个实例(按读入顺序的全局实例 ID 为 0..4):
- 实例列表(meshIndex, materialIndex):[(0, 1), (2, 0), (0, 3), (0, 1), (2, 2)]
- 构造结果:
- transforms[0] 大小 3(对应全局实例 0,2,3),materials[0] = [1,3,1]
- transforms[1] 为空
- transforms[2] 大小 2(对应全局实例 1,4),materials[2] = [0,2]
- indexMap = [(0,0), (2,0), (0,1), (0,2), (2,1)]
- 例如 indexMap[4] = (2,1) 表示全局实例 4 在 mesh 2 中的槽 1
- reverseIDMap[0] = [0,2,3],reverseIDMap[2] = [1,4]
- 用途:
- 后端构建两个 SSBO:实例变换 SSBO 和实例材质索引 SSBO,按 mesh 分段或整体布局
- 渲染循环按 mesh 绑定其顶/索缓冲后,以 instanced draw 的 firstInstance/instanceCount 或通过 SSBO 索引访问实例数据
示例设定与关键约定
- 假设顶点格式固定已知(与项目 shader 一致),例如 Vertex = {pos:vec3, nrm:vec3, uv:vec2},stride=32B
- hdr.indexOffset / hdr.chunkOffset / hdr.materialOffset 以“bulk 区域基址”为起点的字节偏移
- MeshInfo.indexOffset 为“索引元素偏移”(单位是 index 元素,不是字节),index 元素大小假设为 uint32_t
- 每个 mesh 的顶点在全局 vertex buffer 中是顺序拼接,可用 prefix-sum 算 baseVertex
- chunk 元数据结构在文件中紧随其后(本例自定义说明),用于裁剪/细分绘制
一、文件结构(带示例数字)
- 设定(示例值):
- numMeshes=2, numVertices=10,000, numIndices=18,000, numChunks=128, numMaterials=12
- 顶点 stride=32B → 顶点块大小 = 10,000 × 32 = 320,000B
- index 元素=uint32 → 索引块大小 = 18,000 × 4 = 72,000B
- chunk 元数据大小假设 32B/条 → 128 × 32 = 4,096B
- materials 块大小假设 12 × sizeof(MaterialParams),这里假设 64B/条 → 768B
- hdr 偏移(相对 bulk 基址)示例:
- indexOffset = 320,000
- chunkOffset = 320,000 + 72,000 = 392,000
- materialOffset = 392,000 + 4,096 = 396,096
- totalBytes = 顶点 + 索引 + chunk + materials = 320,000 + 72,000 + 4,096 + 768 = 396,864
- meshInfo 表(单位见注):
- mesh 0: indexOffset=0, numTriangles=3,000 → indexCount=9,000; numVertices=6,200; numChunks=70; chunkOffset=0
- mesh 1: indexOffset=9000, numTriangles=3,000 → indexCount=9,000; numVertices=3,800; numChunks=58; chunkOffset=70
- 顶点 base(可运行时计算):baseVertex[0]=0, baseVertex[1]=6,200
- 文件布局(ASCII 图)
[0x000000] uint32 magic 0x55555555
[0x000004] uint32 formatVersion (=1)
[0x000008] StagingHeader hdr { numMeshes, numVertices, numIndices, numChunks, numMaterials,
indexOffset, chunkOffset, materialOffset, totalBytes }
[0x0000??] pad → 256B 对齐
[0x000100] MeshInfo[ numMeshes ] // 每个 mesh: indexOffset(元素), chunkOffset(元素), numTriangles, numVertices, numChunks
[..] uint32 num_lights
[..] LightProperties[ num_lights ]
[..] char textureDir[…]\0
[..] uint32 num_textures
[..] char albedo0\0 char albedo1\0 …
[..] uint32 num_instances
[..] 重复 num_instances 次 { uint32 meshIndex; uint32 materialIndex; glm::mat4x3 txfm; }
[..] pad → 256B 对齐
[bulkBase] Vertex bytes [ numVertices × stride ] // 隐式从 0 开始
[bulkBase + hdr.indexOffset] Index uint32[ numIndices ]
[bulkBase + hdr.chunkOffset] ChunkMeta[ numChunks ] // 例如 32B/条,见下
[bulkBase + hdr.materialOffset] MaterialParams[ numMaterials ]
[bulkBase + hdr.totalBytes] EOF/下一资源
注释
- bulkBase 即第二次 256B 对齐后的文件位置。hdr 内的三个 offset 均以此为基准
- MeshInfo.indexOffset 为“元素偏移”,实际字节偏移 = (bulkBase + hdr.indexOffset) + indexOffset × sizeof(uint32), 这两个offset一个是hdr中的整体offset,一个是meshinfo中的局部offset
二、如何按 index 信息读取 bulk 数据与绘制
- 读取顶点块:seekg(bulkBase + 0) 读取 numVertices × stride(顶点的数据大小)
- 读取索引块:seekg(bulkBase + hdr.indexOffset) 读取 numIndices × 4B(索引数据大小)
- 定位某 mesh 的索引子区:
- firstIndexBytes = (hdr.indexOffset) + mesh.indexOffset × 4
- indexCount = mesh.numTriangles × 3
- 计算 baseVertex(如果需要 DrawIndexed 的 vertexOffset):对 meshInfo.numVertices 做前缀和
- 读取 chunk:seekg(bulkBase + hdr.chunkOffset + mesh.chunkOffset × sizeof(ChunkMeta)) 连续读取 mesh.numChunks 条
https://claude.ai/chat/0bce05b5-185b-4fd1-ac00-f1fc86f0cce5
struct Scene {
std::vector<MeshInfo> meshInfo;
EnvironmentInit envInit;
};
struct EnvironmentInit {
EnvironmentInit(const std::vector<InstanceProperties> &instances,
const std::vector<LightProperties> &lights,
uint32_t num_meshes);
std::vector<std::vector<glm::mat4x3>> transforms;
std::vector<std::vector<uint32_t>> materials;
std::vector<std::pair<uint32_t, uint32_t>> indexMap;
std::vector<std::vector<uint32_t>> reverseIDMap;
std::vector<LightProperties> lights;
std::vector<uint32_t> lightIDs;
std::vector<uint32_t> lightReverseIDs;
};
struct VulkanScene : public Scene {
TextureData textures;
DescriptorSet cullSet;
DescriptorSet drawSet;
LocalBuffer data;
VkDeviceSize indexOffset;
uint32_t numMeshes;
};
- 对每一个cpuTexture, 都会单独调用一次makeTexture, 这个函数是预分配GPU内存,返回VKImage,一些参数以及对齐要求。在外界对齐之后,就可以保存下此时的offset以便以后寻找到这里,就是预分配的起始地址。根据offset和reqs.size就可以取到该图像了。这是一个batch的操作,即先做一层抽象的视图操作,并不实际分配。
- ?reqs.size并没有保存下来,那么是怎样取出图像呢?
- 在所有texture遍历完之后,调用alloc.alloc实际分配一块GPU空间,交给texture_store,并分配cpu上cpu_texture_bytes的stagingBuffer, 用于准备transfer
- 后续在遍历textures的过程中,逐个经历ktx -> staging buffer staging buffer -> gpu 的过程(这里的空间是连续的,但命令是对不同纹理分别记录的)
- 然后是data,对全部的data数据直接分配一整块LocalBuffer(也就是GPU)和一块 HostBuffer (CPU)
- 整体经历ifstream -> stagingbuffer -> gpu 一次提交
- pipeline barrier:
- texture transfer to gpu之前(barrier.srcAccessMask = 0; barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;),需要一个barrier保证在transfer之前,把layout转变为VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
- transfer之后,需要释放资源,并转移队列所有权,从transfer到graphics。需要对data和texture都进行队列族转换,并且一起提交
for (VkImageMemoryBarrier &barrier : texture_barriers) {
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask = 0;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.srcQueueFamilyIndex = dev.transferQF;
barrier.dstQueueFamilyIndex = dev.gfxQF;
}
}
// Transfer queue relinquish geometry
VkBufferMemoryBarrier geometry_barrier;
geometry_barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
geometry_barrier.pNext = nullptr;
geometry_barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
geometry_barrier.dstAccessMask = 0;
geometry_barrier.srcQueueFamilyIndex = dev.transferQF;
geometry_barrier.dstQueueFamilyIndex = dev.gfxQF;
geometry_barrier.buffer = data.buffer;
geometry_barrier.offset = 0;
geometry_barrier.size = load_info.hdr.totalBytes;
// Geometry & texture barrier execute.
dev.dt.cmdPipelineBarrier(
transfer_stage_cmd_, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 1, &geometry_barrier,
texture_barriers.size(), texture_barriers.data());
- 转移出所有权,还需要一个acquire的过程,geometry和texture需要不同的处理
- geometry复用了之前的geometry_barrier,保证transfer队列中的release早于graphics队列中的acquire,并允许顶点输入阶段按READ掩码读取
// Finish moving geometry onto graphics queue family
// geometry and textures need separate barriers due to different
// dependent stages
geometry_barrier.srcAccessMask = 0;
geometry_barrier.dstAccessMask =
VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT | VK_ACCESS_INDEX_READ_BIT;
VkPipelineStageFlags dst_geo_gfx_stage =
VK_PIPELINE_STAGE_VERTEX_INPUT_BIT;
dev.dt.cmdPipelineBarrier(gfx_copy_cmd_, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
dst_geo_gfx_stage, 0, 0, nullptr, 1,
&geometry_barrier, 0, nullptr);
- texture的所有权获取,保证所有texture在shader采样之前被获取。确保写入对shader可见,同时把布局从transfer改成只读采样布局
if (num_textures > 0) {
for (VkImageMemoryBarrier &barrier : texture_barriers) {
barrier.srcAccessMask = 0;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
barrier.srcQueueFamilyIndex = dev.transferQF;
barrier.dstQueueFamilyIndex = dev.gfxQF;
}
// Finish acquiring mip level 0 on graphics queue and transition layout
dev.dt.cmdPipelineBarrier(
gfx_copy_cmd_, VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr,
texture_barriers.size(), texture_barriers.data());
}
- 后续graphics提交的过程中,通过semaphore,保证transfer提交完成之后才开始
cullSet -> 方便的用于剔除
drawSet
// Draw Set Layout
// 0: Vertex buffer 是Gpu data.buffer的第一部分
// 1: sampler ?
// 2: textures 实际上是texture对应的image View
// 3: material params 是Gpu data.buffer的最后一块
gpu中data.buffer 内存布局
- data.buffer(device-local 的 LocalBuffer)按文件头分段:
- [0, hdr.numVertices*sizeof(Vertex)) → 顶点区(drawSet/binding 0)
- [hdr.indexOffset, hdr.indexOffset + numIndices*4) → 索引区(vkCmdBindIndexBuffer 使用)
- [hdr.chunkOffset, hdr.chunkOffset + numChunks*sizeof(MeshChunk)) → 剔除用 chunk 区(cullSet/binding 0)
- [hdr.materialOffset, hdr.materialOffset + numMaterials*sizeof(MaterialParams)) → 材质区(drawSet/binding 3)
- 纹理:
- 多个 VkImage + 各自 VkImageView,统一 VkDeviceMemory 分配与不同 offset 绑定
- 通过 drawSet 的 textures 数组(binding 2)暴露
一般情况的descriptorSet的作用,是作为一个可能会被大量复用的global数据。像vertex,texture的纹理参数,texture view,包括cullSet,都是可能被多次使用的。这样只需要进行一次bind就可以复用
Conclusion
bps3d的batch rendering,本质上有几个关键设计。
- 1. 经过设计的特殊文件结构:实际上load scene使用的文件本身,就包含着整块的,即将被分配到GPU的,vertex,index和chunk,在分配GPU空间的时候,整个copy。
- 2. texture作为一个单独的系统,不同的texture在设计之下,同样在一段连续的GPU空间中,只是分配的过程不是整体copy,而是根据更新的offset逐个copy。
- 3. batch的方式是,通过切分视图的方式,达到渲染一张大的总体视图 = 渲染batch_size个场景。在这个层级上的,渲染数据的GPU内存空间不是连续的,因为不同的场景全都按照上面的方式来加载。但FrameBuffer是整个分配的一大块,相当于只提交了一次绘制命令。
- 4. 双缓冲:同时存在两张总体视图,用来均衡cpu和gpu的负载。
- 5. 取数据的方式:在渲染完成之后,在提交render_cmd的渲染命令的同时,也会提交一个copy命令到同样的graphic_queue中。这个copy是一个预先录制的,把fb的颜色附件和深度附件,copy到一段连续的gpu线性buffer中。顺序是env0.color, env1.color …. env1023.color 。env0.depth…这样就可以通过把这段内存通过cudacopy的方式,复制到host内部取出数据。
- 6. indirect Draw:
- param: MVP | material | viewinfo | light | cull
- indirect: count | draw 通过整合全局资源,统一进行cull和draw操作的提交
- vertex显式读取,index对于每个scene绑定一次
- 7. 优化点核心:减少cmdDraw命令的调用,减少context切换。
问题
- 冲突记录:物理 vs 渲染。mujoco的基础数据结构更像是基于物理仿真的设计,保存的mjtGeom实际上类似物理引擎方便处理的碰撞体架构,而渲染部分则仅仅是一个简单的可视化,并不是针对渲染方向特化的Mesh网格,vertex index这样的结构。本质上,mujoco针对的场景更像是追求高物理精度的低模场景,因此渲染方向本身就不是mujoco专长处理的场景
- 在游戏引擎中,一般会用ECS架构来分别维护物理组件和渲染组件,来避免这样的问题。
- 对于mujoco来讲,对于单个scene来说,需要存储的,这样的整块的数据是什么样的?mjGeom自身维护有transform和texture,是否需要以这个结构体为基本单位构建数据?还是要把这些拆分开来,按照vertex,index这样的形式呢?
- 在处理batch的过程中,manager层级,也就是env的上一层,需要什么必要的数据信息?除了和vulkan相关的基础内容,应该也有很多运行时信息需要保存。
- 是否需要对这样简单的batch形式做出改动?有哪些层级的内容时可以复用的吗?
- 一个显而易见的思考是,把所有opengl依赖的数据结构,model,data,context,scene四项,全部保存在一个新的env类中。一个renderer管理batch_size个env,然后就可以方便的划分他们在一个FB上面占有的空间。
- 观察到,似乎scene的设计与opengl并非是强绑定的,如果可以取出里面的信息,似乎就不会影响updateScene的过程,进而可以仅仅改动render和readPixels环节。
- 核心的数据:vertexBuffer,indexBuffer这两项的数据从哪里来?
- 参照bps3d,大致的流程是
- render:分配整块的FB,envs遍历分别进行cmdDraw,FB.color copy到线性分区
- readPixels:可以直接提取线性分区的color,cudaCopy到分配好的host rgb中,可以按照块大小直接分块,来获取每个分区的render结果
- doc
渲染底层的mjvGeom_的信息整合:
type: 隐藏的vertex + index
dataid:?