Here is a pattern that you would do in C and that you can't reproduce in Rust without a relatively big overhead.
(note that everything is pseudo-code, these are not the actual vulkan functions names and a lot of parameters are missing)
For the sake of this example, let's create a program and a command buffer:
unsigned long long program_id;
vkCreateProgram(vulkan_context, &program_id);
unsigned long long cb_id;
vkCreateCommandbuffer(vulkan_context, &cb_id);
The API manipulates identifiers whose meanings are implementation-defined, but in practice they are probably just raw pointers.
A Vulkan "program" contains code that is executed by the GPU and tells it how it should render 3D objects. A "command buffer" is a list of commands that the GPU must execute. In order to tell the GPU to draw something, we need to add an entry in the command buffer, and this entry contains the program.
vkCommandBufferStart(cb_id);
vkCommandBufferBindProgram(cb_id, program_id);
vkCommandBufferDraw(cb_id);
vkCommandBufferEnd();
This operation stores a pointer to the program in the commands buffer.
The Vulkan API is very low level, and one of its particularities is that you (the programmer) need to make sure that you don't modify or delete the program while the command buffer is still alive and/or in use. If you do that, you will most likely get a crash.
In order to write a safe Rust wrapper around Vulkan, the idiomatic way would be to do this:
pub struct Program {
id: u64,
}
impl Program {
pub fn new(vulkan_context: &Context) -> Program {
let mut id = mem::uninitialized();
vkCreateProgram(vulkan_context, &mut id);
Program { id: id }
}
}
pub struct CommandBuffer<'a> {
id: u64,
dependencies: PhantomData<&'a ()>,
}
impl<'a> CommandBuffer<'a> {
pub fn new_draw_command(vulkan_context: &Context, program: &'a Program) -> CommandBuffer<'a> {
let mut id = mem::uninitialized();
vkCreateCommandbuffer(vulkan_context, &mut id);
vkCommandBufferStart(id);
vkCommandBufferBindProgram(id, program.id);
vkCommandBufferDraw(id);
vkCommandBufferEnd();
CommandBuffer { id: id, dependencies: PhantomData }
}
}
Thanks to the lifetime, you're safe, as you prevent the user from deleting/modifying the program while the command buffer is alive.
However, using a lifetime also means you can't write that:
struct MyAssets {
program: Program,
command_buffer: CommandBuffer<???>, // missing lifetime parameter
}
You must write that instead:
struct MyAssets1 {
program: Program,
}
struct MyAssets2<'a> {
command_buffer: CommandBuffer<'a>,
}
This example was just about programs and command buffers.
In the real Vulkan API, you have:
- Devices
- Programs (would contain a &Device)
- Buffers (would contain a &Device)
- Textures (would contain a &Device)
- Fixed pipeline states (would contain a &Device)
- Program pipelines (would contain a &Program and several &FixedPipelineStates)
- Descriptor sets (would contain several &Buffers and &Textures, and a &ProgramPipeline)
- Command buffers (would contain a &DescriptorSet)
- (some others like fences, samples or queues that I'm not including for the sake of simplicity)
Because you can't store the borrower and borrowed in the same struct, you would end up with something like this:
struct Device {
device: Device,
}
struct MyAssets1<'a> {
programs: Vec<Program<'a>>,
buffers: Vec<Buffer<'a>>,
textures: Vec<Texture<'a>>,
}
struct MyAssets2<'a> {
program_pipelines: Vec<ProgramPipeline<'a>>,
}
struct MyAssets3<'a> {
descriptor_sets: Vec<DescriptorSet<'a>>,
}
struct MyAssets4<'a> {
command_buffers: Vec<CommandBufer<'a>>,
}
Meanwhile in C/C++, you would just put everything in a big struct and handle the lifetimes in an unsafe manner.
The bigger problem is that the fact that you have five different structs that depend on one another "propagates" thoughout the API layers.
If you write a library that abstracts over vulkan-rs
, you will need to have five different structs as well.
If you write another library that abstracts over that library, you will need to have five different structs as well. The fact that you use Vulkan should be an implementation detail, and it would be confusing if the user had to create 5 randomly-named structs on the stack just to make it work.
As far as I know the only way to solve this problem for the moment while remaining safe is to use a reference counter (Arc
).
Alternatively there is a known trick with RefCell
to bypass the restriction, but it's not practical to use.
The overhead of using an Arc
is big compared to a simple PhantomData<&'a ()>
, because:
- You do a memory allocation.
- You do atomic operations.
- You increase the chances of cache misses, as the object identifiers are on the heap.
All while a PhantomData
is totally free and doesn't even exist at all at runtime.
I don't have any suggestion about how to solve this problem (I have already thought about this problem in the past).
For the moment my plan for a potential Vulkan wrapper library is to use generic pointers, like this:
pub struct CommandBuffer<P> where P: Deref<Program> {
command_buffer: u64,
program: P,
}
So that the user can choose between using a &'a Program
(low-overhead, low usability) or a Arc<Program>
(high overhead, high usability). But this solution isn't great.
@Ericson2314 this exists,
for<'a> CommandBuffer<'a>
will work if it's a trait