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_HOMEpointing 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 watchlogcatwhen 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
| Symptom | Cause | Fix |
|---|---|---|
VK_ERROR_INCOMPATIBLE_DRIVER from vkCreateInstance | Asked for a version above what the loader or driver offers | Clamp apiVersion to the value returned by vkEnumerateInstanceVersion |
Undefined reference to vkCreateAndroidSurfaceKHR | vulkan missing from the link line | Add it to target_link_libraries; check the ABI too |
| Black window, no validation message | Present before the render pass ended, or no acquire for this image | End the pass, then submit, then present, in that order |
| Crash on resume after the home button | Surface destroyed with the window, still referenced | Recreate 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.