Skip to main content
Version: 0.9.0 (next)

Vulkan Rendering

What you build​

The Vulkan cube: a rotating cube with one flat color per face, rendered on the GPU in stereo color and depth for every target app in the session. Each target, a Visualizer or a headset, gets its own sender and its own camera. The cube runs on the Hub machine, joins the Hub as a local app, creates its own Vulkan device on the GPU the session uses, and hands each frame to QAROS through the Vulkan render sender without a copy.

Take this path when your renderer already runs on Vulkan. QAROS creates the frame images on your device; you render into them and hand them back.

What you will learn:

  • how to join as a local app, and how to cancel the approval wait on a stop request
  • how to create your Vulkan device on the session's GPU with the extensions QAROS needs
  • how to find the target apps and serve each on a thread of its own
  • the frame hand-off: take each image from the state QAROS reports, render, release it to the external queue family, signal the frame's semaphore, write the new state back, show
  • which handle lives how long, and why the device outlives the session

The cube is C++20, not C: it uses threads, a mutex and std::unique_ptr for the parts that are not QAROS. Every QAROS call is the C API. The program is qar-streaming-c/examples/vulkan_cube/: main.cpp holds everything QAROS, renderer.cpp the Vulkan device and the camera. This page shows their code. The CPU Rendering tutorial explains peers, app volumes and the frame cycle; this one builds on it.

Before you start​

  • Windows 10 or 11, x64. The cube is built for Windows only.
  • A GPU whose Vulkan driver offers VK_KHR_external_memory_win32 and VK_KHR_external_semaphore_win32. QAROS supports NVIDIA GPUs (Choosing the GPU).
  • QAROS installed on the Hub machine, where the cube runs. When the Hub does not run, the join starts it, and the Hub keeps running after the cube exits.
  • Visual Studio 2022 Build Tools ("Desktop development with C++"), CMake 3.29 or newer and Git. The Vulkan headers, the loader, glm and the shader compiler come from vcpkg.
  • What you do: on the first run, click Allow in the QAROS approval prompt, in the Visualizer or in a tray window, within 120 s (The approval prompt). The cube is not code-signed, so QAROS asks again after every rebuild.
  • A target: open the Visualizer, or start a headset that joined the Hub.

Run it​

Build from the root of this repository, in a Developer PowerShell for VS 2022. The first configure downloads vcpkg and builds the dependencies, which takes several minutes:

 cmake --preset x64-windows -DBUILD_VULKAN_EXAMPLE=ON -DVCPKG_MANIFEST_FEATURES=examples-vulkan
 cmake --build --preset x64-windows-release --target qar-vulkan-source
 .\build\x64-windows\qar-streaming-c\examples\vulkan_cube\Release\qar-vulkan-source.exe

The cube takes no arguments. --help prints its usage; any other argument prints the usage to stderr and exits with 1. Stop it with Ctrl+C. When its stdin is a pipe, the end of stdin stops it too: that is how QAROS stops an app it started.

Expected output, with the Visualizer open, for a run that you stop later (your GPU and peer id differ; with console logging on, the runtime adds log lines of its own):

 Joining the QAROS on this machine. On the first run, click Allow in the QAROS approval prompt.
 Connecting to the local QAROS hub.
 Certificate signed.
 Onboarding complete.
 Rendering on <GPU name> (LUID <adapter LUID>), the session's GPU
 Connected. Waiting for rendering targets. Ctrl+C to quit.
 Rendering for target <peer id>

The three lines after Joining are the runtime's progress messages. While the approval prompt is open, one more appears: Waiting for approval in QAROS hub. In the Visualizer, the cube appears in the "Vulkan Cube" app volume, 1.5 m in front of the room origin.

Exit codes:

CodeMeaning
0stopped by Ctrl+C or the end of stdin
1an argument other than --help or -h, qar_library_init or qar_runtime_create failed, or any other failure: a join failure not listed below, the GPU, the app volume, the peer list
2no runtime loaded
3the join could neither start nor reach the Hub (QAR_STATUS_ONBOARDING_HUB_UNREACHABLE)
4the approval was denied or revoked (QAR_STATUS_ONBOARDING_APPROVAL_DENIED)
5nobody answered the approval prompt in time (QAR_STATUS_ONBOARDING_APPROVAL_TIMEOUT)
6the program answering on this machine is not the installed QAROS (QAR_STATUS_ONBOARDING_HUB_NOT_AUTHENTIC)
7the executable no longer matches what QAROS approved (QAR_STATUS_ONBOARDING_APP_IDENTITY_MISMATCH)

Walkthrough​

1. Start, load the runtime, map failures to exit codes​

 int
 main(int argc, char** argv)
 {
  // Print every line as it happens, also when the output goes to a pipe or a
  // file: the C runtime block-buffers redirected output, and MSVC has no line
  // buffering, so stdout (which std::cout writes through) stays unbuffered.
  std::setvbuf(stdout, nullptr, _IONBF, 0);
  if(argc > 1)
  {
  const bool help =
  std::string_view(argv[1]) == "--help" || std::string_view(argv[1]) == "-h";
  (help ? std::cout : std::cerr) << kUsage;
  return help ? 0 : 1;
  }
  std::signal(SIGINT, OnStopSignal);
  std::signal(SIGTERM, OnStopSignal);
  if(StdinIsPipe())
  {
  std::thread(StopWhenStdinCloses).detach();
  }
  // The header's default lookup: no path to find or pass.
  if(not qar_library_load(nullptr))
  {
  std::cerr
  << "Could not load QAROS. Install QAROS on this machine, then start this app again.\n";
  return 2;
  }
  auto libraryInit = qar_library_init_default();
  libraryInit.enable_console_logging = true;
  if(not Check(qar_library_init(&libraryInit)))
  {
  return 1;
  }
  int exitCode = RunApp();
  if(not Check(qar_library_destroy()) && exitCode == 0)
  {
  exitCode = 1;
  }
  std::signal(SIGINT, SIG_DFL);
  std::signal(SIGTERM, SIG_DFL);
  return exitCode;
 }

A signal handler may safely do nothing but write a lock-free atomic, so Ctrl+C only sets g_stopRequested. Every QAROS and GPU cleanup stays on the threads that own the objects. When stdin is a pipe, a thread waits for its end and sets the same flag.

qar_library_load(NULL) runs the default lookup: the runtime beside the executable, the folder QAROS_RUNTIME_DIR names, the installed QAROS, then PATH. It prints where it looked when it finds nothing. The Dynamic Loading tutorial explains each step.

2. The order of the whole program​

 // Everything between loading the library and unloading it. Returns the exit
 // code; a stop request is not a failure.
 int
 RunApp()
 {
  VulkanDevice gpu; // Outlives every sender and camera.
  Targets found; // Outlives session subscription teardown.
  QarRuntime* runtime = nullptr;
  QarSession* session = nullptr;
  // runtime_binaries_folder_path stays empty: the runtime uses the folder it
  // was loaded from.
  const auto runtimeInit = qar_runtime_init_default();
  int exitCode = Check(qar_runtime_create(&runtimeInit, &runtime)) ? 0 : 1;
  if(exitCode == 0)
  {
  std::cout
  << "Joining the QAROS on this machine. On the first run, click Allow in the QAROS "
  "approval prompt.\n";
  const QarResult joined = JoinAsLocalApp(runtime, &session);
  if(not qar_result_is_success(joined) && not g_stopRequested.load())
  {
  exitCode = ExplainOnboardingFailure(joined);
  }
  }
  // Onboard first: the session decides the GPU, and the cube creates its own
  // device on the same one.
  if(session != nullptr && not g_stopRequested.load())
  {
  bool success = gpu.Create(SessionAdapter(session));
  if(success)
  {
  std::cout << "Connected. Waiting for rendering targets. Ctrl+C to quit.\n";
  success = Run(session, gpu, found);
  }
  exitCode = success ? 0 : 1;
  }
  if(session)
  {
  }
  if(runtime)
  {
  }
  return exitCode;
 }

Join first: the session decides the GPU, and the device is created there. Note the order of the declarations. gpu is declared first, so it is destroyed last, after the session. Ownership and teardown explains why.

3. Join as a local app​

A source app on the Hub machine joins as a local app: the user approves it once in QAROS, and there is no code to type. Nothing is stored; every start joins again.

 // Cancels the onboarding call when a stop arrives before it finished. Polls
 // the stop flag, the one thing a signal handler can set.
 void
 CancelOnStop(const std::atomic<bool>* finished, QarCancelToken* cancel)
 {
  while(not finished->load() && not g_stopRequested.load())
  {
  std::this_thread::sleep_for(std::chrono::milliseconds(50));
  }
  if(not finished->load() && not Check(qar_cancel_token_cancel(cancel)))
  {
  std::cerr << "Failed to cancel joining QAROS\n";
  }
 }
 
 // Joins the Hub on this machine as a local app: no code, the user approves the
 // app once in QAROS. No QarGraphicsDeviceId is chained: the session decides the
 // GPU, and the cube creates its device on it. Ctrl+C or a closed stdin cancels
 // the call, which can wait minutes for the approval.
 JoinAsLocalApp(QarRuntime* runtime, QarSession** session)
 {
  init.presentation.display_name = "Vulkan Cube Source";
  init.header.next = &local.header;
  QarCancelToken* cancel = nullptr;
  const QarResult created = qar_cancel_token_create(&cancel);
  if(not qar_result_is_success(created))
  {
  return created;
  }
  std::atomic<bool> finished = false;
  std::thread canceller(CancelOnStop, &finished, cancel);
  QarOnboardingId onboarding{};
  const QarResult result =
  qar_runtime_onboard(runtime, &init, OnProgress, nullptr, cancel, &onboarding, session);
  finished.store(true);
  canceller.join();
  return result;
 }

The call can wait minutes for the approval, so it takes a cancel token. A second thread watches the stop flag and cancels the token when a stop arrives first. A zeroed QarOnboardLocalAppExt::approval_timeout_ms keeps the Hub's default of 120 s. No QarGraphicsDeviceId is chained: QAROS selects the GPU. The progress callback prints the runtime's messages, such as Waiting for approval in QAROS hub.

When joining fails, the cube tells the user what happened and what to do, by status code, and exits with that refusal's own code, the exit codes of Local App Onboarding:

 // Tells the user, in plain words, why joining failed and what to do. Returns
 // the exit code: each refusal needs a different answer from the user, so each
 // has its own code, 3 to 7; anything else is 1
 int
 ExplainOnboardingFailure(QarResult result)
 {
  int exitCode = 1;
  {
  std::cerr << "QAROS did not allow this app to join: the approval was denied, or revoked. "
  "Start the app again to be asked again.\n";
  exitCode = 4;
  }
  {
  std::cerr << "Nobody answered the approval prompt in QAROS in time. It opens in the QAROS "
  "Visualizer, or as a QAROS tray window. Start the app again.\n";
  exitCode = 5;
  }
  {
  std::cerr
  << "The program answering on this machine is not the installed QAROS, so this app "
  "does not trust it and stops. Reinstall QAROS or ask your administrator.\n";
  exitCode = 6;
  }
  {
  std::cerr << "This app no longer matches what QAROS approved: it was moved, copied or "
  "re-signed. In the QAROS Visualizer, revoke it under Connection > Approved "
  "apps, then start it again to approve it anew.\n";
  exitCode = 7;
  }
  {
  // The join starts the Hub when it does not run, so this means it could
  // neither start nor reach one.
  std::cerr
  << "Could not start or reach QAROS on this machine. Start QAROS from the Start menu, "
  "then start this app again.\n";
  exitCode = 3;
  }
  else
  {
  std::cerr << "Could not join QAROS.\n";
  }
  std::array<char, 512> detail{};
  qar_result_message(result, detail.data(), detail.size());
  std::cerr << "Details: " << detail.data() << '\n';
  return exitCode;
 }

These are the codes of Status codes and what to tell the user. Retry none of them silently. A stop during the wait is not a failure: RunApp returns 0 without an explanation.

4. Create the device on the session's GPU​

QAROS creates the frame images on your device, and the receiver reads them on the session's GPU. Render on that GPU, so frames need no copy between GPUs. qar_session_get_graphics_device_id returns the adapter the session uses: the one a chained QarGraphicsDeviceId named, else the one QAROS selected, else the one the library picked.

 // The GPU the session renders on, as the hex VulkanDevice::Create matches. The
 // session is on the GPU of the target QAROS launched this app for, or one the
 // library picked; the cube creates its device on the same one so frames stay
 // in GPU memory. Empty when the session reports none.
 std::string
 SessionAdapter(const QarSession* session)
 {
  bool present = false;
  if(not Check(qar_session_get_graphics_device_id(session, &id, &present)) || not present)
  {
  return {};
  }
  std::array<uint8_t, 16> bytes{};
  if(id.id_type == QAR_GPU_DEVICE_ID_TYPE_LUID)
  {
  std::memcpy(bytes.data(), &id.luid, sizeof(id.luid));
  return AdapterIdHex(bytes.data(), sizeof(id.luid));
  }
  std::memcpy(bytes.data(), id.uuid, sizeof(id.uuid));
  return AdapterIdHex(bytes.data(), sizeof(id.uuid));
 }

VulkanDevice::Create in renderer.cpp takes the first physical device whose VkPhysicalDeviceIDProperties match that LUID or UUID, that has a graphics queue family, and that offers these device extensions:

  // QAROS shares the frame images and the semaphore with your device as
  // external memory and an external semaphore.
 #ifdef _WIN32
  const std::array<const char*, 2> extensions = {
  VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME, VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME
  };
 #else
  const std::array<const char*, 1> extensions = { VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME };
 #endif

When the session names an adapter and no physical device on it qualifies, the cube stops with an error rather than render on another GPU. When the session names none, it takes the first supported GPU and says so.

5. Find the targets​

The app volume is the cube's own. A sender attaches its target to it, and that is what makes the target ask for the content:

  auto volumeInit = qar_app_volume_init_default();
  volumeInit.common_name = "vulkan-cube";
  volumeInit.display_name = "Vulkan Cube";
  volumeInit.pose.position.z = -1.5f;
  volumeInit.size = { 1.2f, 1.2f, 1.2f };
  Scene scene{ .gpu = &gpu, .session = session, .started = Clock::now() };
  if(not Check(qar_app_volumes_get_or_create(session, &volumeInit, &scene.volume)))
  {
  return false;
  }

Which targets to serve is the source app's choice. The cube serves every target app that is not gone. The peer callback runs on a library thread, so it only queues ids under a lock:

 // A target can be rendered for unless the hub says it is gone or it announced its leave. UNKNOWN
 // is a hub that has not reconciled the peer yet (or an older hub that never does): nothing says
 // it is gone, so it counts.
 bool
 IsReachable(QarPeerPresence presence)
 {
  return presence == QAR_PEER_PRESENCE_ONLINE || presence == QAR_PEER_PRESENCE_UNKNOWN;
 }
 
 // The cube renders for every running target app in the session: which targets
 // to serve is the source app's own choice, and this one serves them all. Each
 // sender attaches its target to the cube's app volume, which is what makes the
 // target ask for the content.
 //
 // The peer callback runs on a library thread and hands over IDs only; main
 // starts the targets. A plain std::mutex guards the hand-over.
 struct Targets
 {
  std::mutex lock;
  std::map<PeerKey, QarPeerId> pending;
  // Queues the peer when it is a reachable target app.
  void Offer(QarPeerSpec* spec)
  {
  if(not Check(qar_peer_spec_get_role(spec, &role)) || role != QAR_PEER_ROLE_TARGET)
  {
  return;
  }
  QarPeerId id{};
  if(not Check(qar_peer_spec_get_presence(spec, &presence)) || not IsReachable(presence)
  || not Check(qar_peer_spec_get_id(spec, &id)))
  {
  return;
  }
  PeerKey key{};
  std::memcpy(key.data(), id.data, key.size());
  const std::lock_guard<std::mutex> guard(lock);
  pending.insert_or_assign(key, id);
  }
  static void OnPeerUpdate(QarPeerSpec* spec, void* context)
  {
  static_cast<Targets*>(context)->Offer(spec);
  }
  // The peers already in the session when the cube starts.
  bool OfferCurrentPeers(QarSession* session)
  {
  size_t count = 0;
  if(not Check(qar_query_peer_specs_count(session, &count)))
  {
  return false;
  }
  // Right after onboarding the session may know no peer yet; the
  // subscription reports them as they arrive.
  if(count == 0)
  {
  return true;
  }
  std::vector<QarPeerSpec*> specs(count, nullptr);
  size_t written = 0;
  const bool listedAll =
  Check(qar_query_peer_specs(session, specs.data(), specs.size(), &written));
  // A failed query may still have written some specs: destroy those too.
  const size_t owned = std::min(written, specs.size());
  for(size_t index = 0; index < owned; ++index)
  {
  if(specs[index] == nullptr)
  {
  continue;
  }
  if(listedAll)
  {
  Offer(specs[index]);
  }
  }
  return listedAll;
  }
  std::map<PeerKey, QarPeerId> Take()
  {
  std::map<PeerKey, QarPeerId> taken;
  const std::lock_guard<std::mutex> guard(lock);
  taken.swap(pending);
  return taken;
  }
 };

Subscribe first, then list the peers already there, so a target that joins in between is not missed. The main thread then manages targets only: it starts one for each new target app, restarts one that failed after 3 s, and forgets one whose stream closed for good.

  // The SDK requires a token for a subscription. Targets outlives the
  // session, so cancelling at the end of Run is enough. Subscribing before
  // listing the current peers leaves no gap for a target joining in between.
  QarCancelToken* subscription = nullptr;
  if(not Check(qar_cancel_token_create(&subscription)))
  {
  return false;
  }
  if(not Check(qar_peer_subscribe_updates(session, Targets::OnPeerUpdate, &found, subscription))
  || not found.OfferCurrentPeers(session))
  {
  Check(qar_cancel_token_cancel(subscription));
  return false;
  }
  std::map<PeerKey, QarPeerId> wanted;
  std::map<PeerKey, std::unique_ptr<Target>> targets;
  std::map<PeerKey, Clock::time_point> retries;
  while(not g_stopRequested.load())
  {
  const auto now = Clock::now();
  for(const auto& [key, id] : found.Take())
  {
  wanted.insert_or_assign(key, id);
  }
  for(const auto& [key, id] : wanted)
  {
  if(not targets.contains(key) && now >= retries[key])
  {
  targets.emplace(key, std::make_unique<Target>(id, scene));
  }
  }
  for(auto it = targets.begin(); it != targets.end();)
  {
  if(not it->second->Finished())
  {
  ++it;
  continue;
  }
  if(it->second->StreamClosed())
  {
  wanted.erase(it->first);
  }
  retries[it->first] = now + std::chrono::seconds(3);
  it = targets.erase(it);
  }
  // Control plane only: how soon a new or failed target is noticed.
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
  }
  // Stop every target first so their waits end together, then join them.
  for(auto& [key, target] : targets)
  {
  target->RequestStop();
  }
  targets.clear();
  bool success = Check(qar_cancel_token_cancel(subscription));
  return success;

6. One sender per target​

Each Target runs on a thread of its own. It creates its sender, then renders until it is stopped or its stream fails. A target that never answers blocks only its own thread.

  bool CreateSender()
  {
  auto vulkan = qar_stream_params_vulkan_default();
  vulkan.physical_device = m_scene.gpu->physical;
  vulkan.device = m_scene.gpu->device;
  init.header.next = &vulkan.header;
  init.graphics_api = QAR_GRAPHICS_API_VULKAN;
  init.peer_id = m_peer;
  init.app_volume_id = &m_scene.volume;
  init.color_format = QAR_PIXEL_FORMAT_R8G8B8A8;
  init.depth_format = QAR_PIXEL_FORMAT_D32_FLOAT;
  init.texture_layout = QAR_FRAME_LAYOUT_SEPARATED_TEXTURES;
  init.frame_views_count = 4;
  for(size_t eye = 0; eye < 2; ++eye)
  {
  for(size_t type = 0; type < 2; ++type)
  {
  const size_t index = eye * 2 + type;
  init.frame_views[index] = qar_render_frame_view_default();
  init.frame_views[index].eye =
  init.frame_views[index].data_type =
  init.frame_views[index].texture_index = static_cast<uint32_t>(index);
  }
  }
  // Blocks until the target asks for the stream; this thread has
  // nothing else to do meanwhile, and RequestStop() cancels it.
  const QarResult created =
  qar_render_sender_create(m_scene.session, &init, m_cancel, &m_sender);
  if(not m_stop.load())
  {
  NoteClosed(created);
  return Check(created);
  }
  return false;
  }

QarStreamParamsVulkan, chained into the init, hands QAROS your physical device and device. Each eye gets a color and a depth texture of its own, the QAR_FRAME_LAYOUT_SEPARATED_TEXTURES layout, four views in all. qar_render_sender_create blocks until the target answers; RequestStop cancels it through the target's token.

7. One frame​

  // One frame: begin (paced by the target), render, show. Returns false when
  // this target cannot go on.
  bool RenderNext()
  {
  QarRenderFrameInfo* begunInfo = nullptr;
  const QarResult begun = qar_render_sender_begin_frame(m_sender, m_cancel, &begunInfo);
  // The frame info describes the begun frame: keep it until that frame is
  // shown, as the CPU and D3D11 samples do.
  const FrameInfo info(begunInfo);
  if(not qar_result_is_success(begun))
  {
  return ContinueAfterFailedBegin(begun);
  }
  // From here a stop request waits for this frame: a begun frame is
  // shown, even when rendering failed, because the show closes the frame
  // cycle. The show waits for the frame's semaphore, which comes with the
  // record: without the record nothing could signal it, so a failed read
  // ends the target without a show, and its sender goes with it.
  QarVideoFrameVulkan* frame = nullptr;
  if(not Check(qar_render_sender_frame_vulkan(m_sender, &frame)))
  {
  return false;
  }
  if(not RenderInto(*frame, info.get()))
  {
  // The discard signals the semaphore, so the show can close the
  // cycle; the target ends afterwards.
  if(m_camera->Discard(*frame))
  {
  Check(Show());
  }
  else
  {
  std::cerr << "Failed to discard camera frame\n";
  }
  return false;
  }
  const QarResult shown = Show();
  if(IsWaiting(shown))
  {
  return true;
  }
  NoteClosed(shown);
  return Check(shown);
  }
 
  QarResult Show()
  {
  show.rendered_near_far = { .1f, 10.f };
  return qar_render_sender_show_frame(m_sender, &show);
  }
 
  bool ContinueAfterFailedBegin(QarResult begun)
  {
  if(m_stop.load())
  {
  // RequestStop() cancelled the wait: not an error to report.
  return false;
  }
  if(IsWaiting(begun))
  {
  // Begin-frame waits through a pause without reporting it, and
  // holds an unresolved app volume about 16 ms before reporting it.
  // A reconnecting or transfer-less stream has no such hold, so wait
  // a moment before the next begin instead of spinning.
  std::this_thread::sleep_for(std::chrono::milliseconds(2));
  return true;
  }
  Check(begun);
  NoteClosed(begun);
  return false;
  }

qar_render_sender_begin_frame paces the thread: it returns when this target wants its next frame, so each target runs at its own display rate, and a slow or paused one never delays another. The frame path sleeps only after a waiting status, 2 ms before the next begin.

The frame info it returns describes the begun frame. Keep it until qar_render_sender_show_frame has shown that frame. The FrameInfo type, a std::unique_ptr whose deleter calls qar_render_frame_info_handle_destroy, destroys it on every path out of RenderNext, after the show.

A begun frame is always shown, as in the CPU and D3D11 programs: the show closes the frame cycle that begin opened. When rendering fails, the camera first discards the frame by signaling its semaphore, the cube shows it, and the target ends. So a stop request that arrives after a successful begin waits for that one frame, and the thread stops at the next turn of its loop. One case differs, for a Vulkan reason: the show waits for the frame's semaphore, which comes with the frame record. When reading the record fails, nothing can signal it, so the target ends without a show and its sender is destroyed with it.

The pose and the field of view of each eye come from the view showing that eye's color. The poses are in the app volume's app content space, the space the cube renders in:

  bool RenderInto(QarVideoFrameVulkan& frame, QarRenderFrameInfo* info)
  {
  std::array<CameraView, 2> cameras{};
  for(size_t eye = 0; eye < 2; ++eye)
  {
  // Each eye's pose and field of view belong to the view showing its
  // color. Finding it by eye and type keeps this right whatever order
  // the views were declared in.
  const int view = FindFrameView(
  frame,
  );
  if(view < 0
  info, static_cast<size_t>(view), &cameras[eye].pose
  ))
  info, static_cast<size_t>(view), &cameras[eye].fov
  )))
  {
  return false;
  }
  }
  return m_camera->Render(frame, cameras, m_scene.Seconds());
  }

A failed begin-frame or show is not always the end of the target:

 // A stream that is paused (its receiver's display is not visible), reconnecting
 // or has no transfer yet is not broken: keep the sender and try the next frame.
 // Recreating it would only start the negotiation over. An app volume that does
 // not resolve yet is recoverable too: begin-frame starts no frame then, and a
 // frame begins once the volume resolves. qar_result_has_code also matches a
 // wrapped cause: begin-frame reports these inside
 // QAR_STATUS_RENDERING_PRODUCER_UNABLE_TO_DO_BEGIN_FRAME.
 bool
 IsWaiting(QarResult result)
 {
 }

Reconnecting and transfer-less streams keep their sender, and so does an app volume that does not resolve yet; begin-frame holds the unresolved volume about 16 ms before it reports it. A paused stream does not show up here: begin-frame waits through the pause. Any other failure ends the target, QAR_STATUS_RENDERING_PRODUCER_STREAM_IS_CLOSED included; a closed stream is forgotten until the peer list offers the target again (Status codes that are part of normal flow).

8. Hand the images back​

qar_render_sender_frame_vulkan gives the sender's frame: its views, its images with their current layout and owning queue family, and the frame's semaphore. The pointer stays valid until the next call to it or the sender's destruction. QAROS owns the images, their memory and the semaphore: never destroy, free or re-bind them.

The camera takes each image from exactly the state the frame reports, and leaves it in VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, released to VK_QUEUE_FAMILY_EXTERNAL:

  // Each image is taken from the layout and queue family the frame reports,
  // and handed back in TRANSFER_SRC_OPTIMAL, released to the external queue
  // family.
  std::array<VkImageMemoryBarrier, 4> acquire{}, release{};
  for(size_t i = 0; i < frame.textures_count; ++i)
  {
  auto& texture = frame.textures[i];
  const bool depth = texture.size.format == QAR_PIXEL_FORMAT_D32_FLOAT;
  VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER };
  barrier.oldLayout = texture.layout;
  barrier.newLayout = depth ? VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL
  : VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
  barrier.srcQueueFamilyIndex = texture.queue_family_index;
  barrier.dstQueueFamilyIndex = m_gpu->family;
  if(texture.queue_family_index == VK_QUEUE_FAMILY_IGNORED
  || texture.queue_family_index == m_gpu->family)
  {
  barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
  }
  barrier.dstAccessMask = depth ? VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT
  : VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
  barrier.image = texture.image;
  barrier.subresourceRange = { static_cast<VkImageAspectFlags>(
  depth ? VK_IMAGE_ASPECT_DEPTH_BIT
  : VK_IMAGE_ASPECT_COLOR_BIT
  ),
  0,
  1,
  0,
  1 };
  acquire[i] = barrier;
  barrier.srcAccessMask = barrier.dstAccessMask;
  barrier.dstAccessMask = 0;
  barrier.oldLayout = barrier.newLayout;
  barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
  barrier.srcQueueFamilyIndex = m_gpu->family;
  barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_EXTERNAL;
  release[i] = barrier;
  }

Then, in this order: submit the work that signals the frame's semaphore, write the images' new state back into the frame, and only then show:

  // The semaphore hands the frame to the sender, which waits for it on the
  // GPU; the fence only tells this camera when the slot is free again.
  VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO };
  submit.commandBufferCount = 1;
  submit.pCommandBuffers = &slot->commands;
  if(frame.synchronization.semaphore)
  {
  submit.signalSemaphoreCount = 1;
  submit.pSignalSemaphores = &frame.synchronization.semaphore;
  }
  if(not m_gpu->Submit(submit, slot->fence, "submit cube frame"))
  {
  return false;
  }
  for(size_t i = 0; i < frame.textures_count; ++i)
  {
  frame.textures[i].layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
  frame.textures[i].queue_family_index = VK_QUEUE_FAMILY_EXTERNAL;
  }
  frame.synchronization.fence = VK_NULL_HANDLE;
  return true;

The camera keeps two frames in flight. It waits on a slot's fence only when it reuses that slot, never for the frame it just submitted: the semaphore hands that work to QAROS, which waits for it on the GPU.

A begun frame that could not be rendered still signals its semaphore, so the show that closes its cycle has something to wait for:

 bool
 CubeCamera::Discard(QarVideoFrameVulkan& frame)
 {
  // A begun frame that could not be rendered still signals the borrowed
  // semaphore, so the show that closes the frame cycle has something to
  // wait for.
  Slot* slot = nullptr;
  if(not NextSlot(slot))
  {
  return false;
  }
  VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO };
  if(frame.synchronization.semaphore)
  {
  submit.signalSemaphoreCount = 1;
  submit.pSignalSemaphores = &frame.synchronization.semaphore;
  }
  return m_gpu->Submit(submit, slot->fence, "signal discarded camera frame");
 }

Image handles can change between frames. Read the frame every frame. The camera caches its image views and framebuffers by image handle, so a new handle gets new ones.

Ownership and teardown​

ObjectCreated byLives until
QarRenderFrameInfo*qar_render_sender_begin_framethe frame is shown or discarded; FrameInfo destroys it
QarVideoFrameVulkan*qar_render_sender_frame_vulkanthe next call to it, or the sender's destruction; never freed by you
frame images, memory, semaphoreQAROSthe sender; never destroyed by you
camera (views, framebuffers, fences)CubeCamera::Createthe Target, before its sender
QarRenderSender*qar_render_sender_createthe Target; qar_render_stream_handle_destroy
target tokenqar_cancel_token_createthe Target, after its sender
subscription tokenqar_cancel_token_createthe end of Run, after every target
QarSession*qar_runtime_onboardRunApp, before the runtime and the device
VulkanDeviceVulkanDevice::CreateRunApp, last
  • Stop before join. Run asks every target to stop first, so all their waits end together, then destroys them. ~Target stops the thread and joins it before it touches anything the thread uses.
  • Camera before sender. The camera waits for its own GPU work, then destroys its views and framebuffers over the sender's images while those images still exist. Then the sender goes, then its token.
  • Subscription before its state. Targets lives in RunApp, longer than the subscription that writes into it. Cancelling the subscription's token waits for a running callback, so nothing writes into Targets afterwards.
  • Session before device. QAROS keeps using your device until the session is gone, not only until the senders are. RunApp declares the device first, so C++ destroys it after the session and the runtime.
  • Library last. main calls qar_library_destroy, then qar_library_unload.

Troubleshooting​

The cube saysCauseWhat to do
Could not load QAROS. Install QAROS on this machine, then start this app again. (exit 2)no runtime in the default lookup; the line before says where it lookedinstall QAROS, or fix QAROS_RUNTIME_DIR
an error logged by qar_library_init (exit 1)the runtime loaded but did not initializeread the message; check the QAROS installation
Could not start or reach QAROS on this machine. Start QAROS from the Start menu, then start this app again. (exit 3)QAR_STATUS_ONBOARDING_HUB_UNREACHABLE: the join could not start the Hub, or not reach itstart QAROS from the Start menu, then the cube
QAROS did not allow this app to join ... (exit 4)QAR_STATUS_ONBOARDING_APPROVAL_DENIED: Deny, or a revoked approvalstart it again and click Allow
Nobody answered the approval prompt in QAROS in time. ... (exit 5)QAR_STATUS_ONBOARDING_APPROVAL_TIMEOUTstart it again and answer within 120 s
The program answering on this machine is not the installed QAROS ... (exit 6)QAR_STATUS_ONBOARDING_HUB_NOT_AUTHENTICreinstall QAROS; do not retry
This app no longer matches what QAROS approved ... (exit 7)QAR_STATUS_ONBOARDING_APP_IDENTITY_MISMATCH: the executable moved, was copied or re-signedrevoke it in the Visualizer, then start it again
The session's GPU (...) names no graphics GPU with external memory/semaphore support (exit 1)no Vulkan device on the session's adapter has a graphics queue and both extensionsupdate the GPU driver; check that the GPU is supported
Connected. Waiting for rendering targets. and nothing moreno target app runs in the session, or none answered its sender yetopen the Visualizer, or start a headset
Expected separated stereo color/depth layoutthe sender reported another layout than the cube asked fornone in your setup; the cube ends that target and tries it again after 3 s
no more frames after the "Vulkan Cube" volume was removedbegin-frame reports QAR_STATUS_RENDERING_PRODUCER_APP_VOLUME_UNRESOLVED; a removed volume never resolves, and the cube does not recreate itstart the cube again
your own app crashes at exitthe Vulkan device was destroyed before the sessiondestroy the session first, the device last

Next​


Generated via doxygen2docusaurus 2.2.1 by Doxygen 1.9.8.