loading...

Fully encapsulating vulkan and win32 in mruby

roryo profile image Rory O'Connell ・5 min read

After about a week I finally finished pushing all of my original prototype work into the mruby VM. Previously the program was a C program which called into the mruby VM once per frame. Now it's the opposite, the C program does just enough to set up the VM state and then hand over control into the VM. For example, the main function originally looked like

int main() {
  ApplicationState *state = (ApplicationState *)VirtualAlloc(0, sizeof(ApplicationState), MEM_COMMIT, PAGE_READWRITE);

  state->ruby_state = mrb_open();
  create_GUI_class(state->ruby_state);

  load_ruby_files(state);
  mrb_sym world_sym = mrb_intern_cstr(state->ruby_state, "World");
  state->world_const = mrb_const_get(state->ruby_state,
                                     mrb_obj_value(state->ruby_state->object_class),
                                     world_sym);

  start_gui(state);
  loop(state);
  vkDeviceWaitIdle(state->vulkan_state.device);

  ImGui_ImplVulkan_Shutdown();
  ImGui_ImplWin32_Shutdown();
  ImGui::DestroyContext();

  CleanupVulkanWindow(state);
  CleanupVulkan(&state->vulkan_state);

  mrb_close(state->ruby_state);

  return 0;
}

The loop function was lengthy. Abbreviated, it looked like

void loop(ApplicationState *state) {
  MSG msg = {};

  while (msg.message != WM_QUIT) {
    ImGui_ImplVulkan_NewFrame();
    ImGui_ImplWin32_NewFrame();

    ImGui::NewFrame();
    int ai = mrb_gc_arena_save(state->ruby_state);
    mrb_funcall(state->ruby_state, state->world_const, "render", 0);
    mrb_gc_arena_restore(state->ruby_state, ai);
    ImGui::Render();
    FrameRender(state);
    FramePresent(state);
  }
}

This makes it more clear what I meant. The main program loop is C with the program state in an ApplicationState struct. It performed all of it's work in C, then yielded to the VM calling the Ruby code World.render, which I covered before. Control comes back to C where it finishes rendering the frame. This did work and I proved to myself the possibility of the idea I have in my mind. The switching of control back and forth between C and the VM caused stability issues. The wrong Ruby incantation causes the program crashing down.

There is also a secondary issue concerning me. I noticed that every frame the object count increased, causing a GC every few seconds.

This bothered me. From other experiences I understood calling back and forth between C and the VM could generate Proc objects. I was hoping that eliminating frequent calls to the VM would reduce or eliminate the amount of object garbage generated.

Now the main program looks like this

int main() {
  mrb_state *state = mrb_open();
  create_GUI_class(state);
  create_VulkanState_class(state);
  create_W32Window_class(state);
  create_Imgui_Impl_class(state);

  // we could do this within the VM now
  load_ruby_files(state);

  mrb_value World = mrb_obj_value(mrb_module_get(state, "World"));
  mrb_funcall(state, World, "start", 0);
  mrb_close(state);

  return 0;
}

The program does just enough to set up the VM and then passes control over to it. It creates four objects from C for interfacing with low level libraries like Vulkan and Win32.

The main entry point for the VM is in World.start

module World

def self.start
  @native_window = W32Window.new 'D E A R G', width: 1920, height: 1080
  # TODO see comments in VulkanState.cpp, need support for setting width/height
  # different from the native window eventually
  # shoutouts to wayland being massively different than every other
  # window system for no real good reason
  @vulkan_state = VulkanState.new @native_window
  ImguiImpl.startup @vulkan_state, @native_window

  while !@is_finished
    @native_window.process_window_messages
    ImguiImpl.new_frame
    process_windows
    ImguiImpl.render @vulkan_state
  end

rescue => ex
  puts "woah #{ex}"
  raise ex
ensure
  ImguiImpl.shutdown
end
end

This should now make it completely clear on what I mean by the program is now a Ruby program and not a C program. The main program loop runs Ruby methods. Some of the method implementations are in C. For example, the W32Window looks somewhat like the W32Window class I used as a research project. The VulkanState class is 500 lines of C performing all the Vulkan initialization ceremony. There's no need to cover that here, and I'm writing a book on Vulkan for that anyway! As an aside while going through the Vulkan implementation I discovered I was using chapters of my own book as reference over anything else I found. Good confidence boost that my authoring project is worthwhile.

We create module instance variables because native windows and Vulkan favor creating instances of data. We also place them in the World module as module instance variables so they aren't ever accidentally garbage collected. OpenGL has an internal hidden global state and wouldn't lend itself to this pattern. The same for imgui, it has an internal hidden state we interface with through C functions. That's why ImguiImpl is a global module with static methods.

Speaking of garbage collection I discovered an interesting and useful property of the mruby VM. When calling mrb_close(state), terminating the VM, the VM actually garbage collects all the remaining objects left in the VM! This gives us a chance for cleaning up the low level resources which I use for deallocating Vulkan objects. Similar to the old C++ RAII pattern. We can do this by declaring a custom deallocation function when registering the type with the VM. Usually we use the standard mrb_free

static const mrb_data_type VulkanState_type = { "VulkanState", VulkanState_delete };

Then we have an opportunity for destroying Vulkan objects correctly

void
VulkanState_delete(mrb_state* state, void* thing) {
  VulkanState* st = reinterpret_cast<VulkanState *>(thing);
  vkDeviceWaitIdle(st->device);
  // lots of vulkan yadda
  mrb_free(state, st);
}

Running it we have exactly the same result as before the move into the VM

Exactly the same, including the issue where it generates significant garbage every frame. That's a result I didn't expect since most of the execution happens within imgui and Vulkan C code. There's something else going on inside the VM I don't understand yet.

One other interesting note. Unlocking the frame rate and letting the system run as fast as possible results in a couple thousand FPS. Frame execution times are between 200 and 1000 nanoseconds, or 0.2 to 1.0 ms. This is without any optimizations, with garbage collection happening every few frames, and a debug binary. It encourages me that the speed of the VM won't become a hindrance later down the line. It also sticks in my mind that in the far future I'll need some threading systems separating the VM execution from the frame limit.

Next up I want to understand what all the object allocations are. The ObjectSpace and GC Ruby modules have some methods on interrogating the state of the VM memory and the statistics within. However, all of these methods perform a full GC on each method call. What I want are statistics on the metrics of the VM at that moment in time without any GC performed. That way I can perform historical analysis on the volume of objects generated and potentially where they came from. Since the entire project is within the VM building anything is a great deal faster and safer than previously. Interesting how a typical development cycle is the first tools you build on a new tool are tools to help you understand the tools you just built.

To build such tools I must have an understanding on how the mruby VM arranges and performs memory book keeping. That will be my next treatise, a full explanation on the mruby memory management system.

Posted on by:

Discussion

pic
Editor guide