A first Vulkan frame is mostly bookkeeping. There is no scene, no shader and no mesh: the goal is a window that presents a colour, with every object created in an order the driver accepts. Once that works, adding a pipeline is an incremental change rather than a leap.

This walkthrough uses the NDK directly, a NativeActivity, and about 400 lines of C++. It assumes NDK r28 or newer and a phone on Vulkan 1.1. It does not use the Android Game Development Kit, because the point is to see the pieces.

What you need

  • The NDK, unpacked, with ANDROID_NDK_HOME pointing at it.
  • CMake 3.22 or newer, and Ninja.
  • A device that reports Vulkan 1.1 or newer. Check with an app that prints vkEnumerateInstanceVersion, or watch logcat when a known Vulkan app starts.
  • Roughly an afternoon. Most of it goes into the swapchain, not the instance.

The project on disk

first-frame/
  CMakeLists.txt
  app/AndroidManifest.xml
  app/src/main/cpp/main.cpp

No Java or Kotlin sources. NativeActivity is provided by the platform, and the manifest tells it which native library to load. Set android:hasCode="false" so the build system does not look for a classes.dex that will never exist.

CMakeLists.txt

cmake_minimum_required(VERSION 3.22)
project(first_frame CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(game SHARED app/src/main/cpp/main.cpp)

target_link_libraries(game
  android
  log
  vulkan)

vulkan resolves to the NDK's stub libvulkan.so. Linking the stub is not optional: it is what makes the loader available at run time, and it is why a missing vkCreateAndroidSurfaceKHR at link time almost always means the library line is wrong.

The manifest and the activity

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-feature android:name="android.hardware.vulkan.version"
                android:version="0x00401000" android:required="true"/>
  <application android:hasCode="false" android:label="First frame">
    <activity android:name="android.app.NativeActivity"
              android:exported="true"
              android:configChanges="orientation|screenSize">
      <meta-data android:name="android.app.lib_name" android:value="game"/>
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>
  </application>
</manifest>

The version value is Vulkan 1.1 encoded with VK_MAKE_VERSION(1, 1, 0), which is 0x00401000. A device without that feature will not see the app in the store, which is the honest filter; the alternative is a crash on launch that looks like a driver bug.

Instance, surface, swapchain

Three objects, created in that order, each with a dependency on the previous one. The instance needs two extensions on Android: the platform surface extension and the Android-specific surface extension.

#include <android/native_window.h>
#include <vulkan/vulkan.h>

VkInstance create_instance() {
  uint32_t api = VK_API_VERSION_1_1;
  vkEnumerateInstanceVersion(&api);   // the loader's own version

  const char* ext[] = {
    VK_KHR_SURFACE_EXTENSION_NAME,
    VK_KHR_ANDROID_SURFACE_EXTENSION_NAME,
  };
  VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
  app.apiVersion = api;               // never ask for more than you got

  VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
  info.pApplicationInfo = &app;
  info.enabledExtensionCount = 2;
  info.ppEnabledExtensionNames = ext;

  VkInstance instance = VK_NULL_HANDLE;
  if (vkCreateInstance(&info, nullptr, &instance) != VK_SUCCESS)
    return VK_NULL_HANDLE;
  return instance;
}

The sequence of surface creation is short and the type is easy to get wrong: it takes the ANativeWindow* that arrives in ANativeActivity_onCreate, wrapped in a VkAndroidSurfaceCreateInfoKHR. The window is owned by the activity; do not destroy it yourself.

VkAndroidSurfaceCreateInfoKHR sci{
    VK_STRUCTURE_TYPE_ANDROID_SURFACE_CREATE_INFO_KHR};
sci.window = window;                 // ANativeWindow* from the activity
VkSurfaceKHR surface = VK_NULL_HANDLE;
vkCreateAndroidSurfaceKHR(instance, &sci, nullptr, &surface);

The swapchain is where most of the afternoon goes. Query the surface formats and present modes first, pick VK_FORMAT_R8G8B8A8_UNORM if it is offered, and use VK_PRESENT_MODE_FIFO_KHR for the first version, because it is the only mode every device must support. Everything else is a preference you can tune after the colour appears.

VkSwapchainCreateInfoKHR ci{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR};
ci.surface = surface;
ci.minImageCount = 3;                // double buffering + 1 for the queue
ci.imageFormat = format;             // from vkGetPhysicalDeviceSurfaceFormatsKHR
ci.imageColorSpace = colorSpace;
ci.imageExtent = {width, height};
ci.imageArrayLayers = 1;
ci.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
ci.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
ci.preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
ci.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
ci.presentMode = VK_PRESENT_MODE_FIFO_KHR;
ci.clipped = VK_TRUE;

Do not hardcode the extent to the display size. The surface tells you its current size in VkSurfaceCapabilitiesKHR; when currentExtent is 0xFFFFFFFF, the platform is letting you choose, and the window bounds are the answer.

The first clear

A render pass with one colour attachment, a framebuffer per swapchain image, one command buffer per frame in flight, and a clear value. The draw itself is a begin and an end with nothing between them.

VkClearValue clear{};
clear.color = {0.05f, 0.06f, 0.07f, 1.0f};

VkRenderPassBeginInfo rp{VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
rp.renderPass = pass;
rp.framebuffer = framebuffers[image_index];
rp.renderArea = {0, 0, width, height};
rp.clearValueCount = 1;
rp.pClearValues = &clear;

vkCmdBeginRenderPass(cmd, &rp, VK_SUBPASS_CONTENTS_INLINE);
vkCmdEndRenderPass(cmd);

Present with vkQueuePresentKHR and handle two return values that are not failures: VK_ERROR_OUT_OF_DATE_KHR and VK_SUBOPTIMAL_KHR. Both mean the swapchain needs recreating, and the second one means the frame still presented, so the current frame is fine and the next one needs a new swapchain.

Four errors and what they mean

SymptomCauseFix
VK_ERROR_INCOMPATIBLE_DRIVER from vkCreateInstanceAsked for a version above what the loader or driver offersClamp apiVersion to the value returned by vkEnumerateInstanceVersion
Undefined reference to vkCreateAndroidSurfaceKHRvulkan missing from the link lineAdd it to target_link_libraries; check the ABI too
Black window, no validation messagePresent before the render pass ended, or no acquire for this imageEnd the pass, then submit, then present, in that order
Crash on resume after the home buttonSurface destroyed with the window, still referencedRecreate surface, swapchain and framebuffers in APP_CMD_INIT_WINDOW

The validation layer is worth the 20 percent frame time it costs during bring-up. It catches the first and fourth rows in seconds, and both are the kind of bug that looks like a driver problem until you read the message.

Next

Once the clear colour is on screen, the next useful step is not a triangle; it is a trace. The article on profiling one frame shows how to measure the path from input to present, and the memory article covers what the swapchain and its images cost on a mid-range device. The full sample, including the swapchain recreation path, is described step by step in the project notes.