A tiny implicit Vulkan layer that controls which swapchain surface format an application ends up using — primarily to fix the red/blue channel swap tint seen with some Android Vulkan drivers/wrappers.
Because it is a Vulkan layer, it sits above the ICD, so it works with every
graphics driver/wrapper without rebuilding any of them:
wrapper, wrapper-leegao, wrapper-v2, wrapper-legacy, turnip, vortek,
freedreno, … one .so, all drivers.
Status: experimental, for device testing.
The "wrong colors" problem is a channel-order mismatch: the app/DXVK renders in
one order (e.g. B8G8R8A8) while the WSI scans out the other (R8G8B8A8), so red
and blue come out swapped. This layer fixes that by steering format negotiation —
zero-copy, it never touches pixels.
Three modes (env GAMENATIVE_SF_MODE):
| Mode | What it does | Cost |
|---|---|---|
prefer (default) |
Reorders vkGetPhysicalDeviceSurfaceFormats(2)KHR so the target channel order is first. Most apps pick formats[0]. |
zero-copy |
filter |
Same, but also hides the non-target orders so the app must use the target. | zero-copy |
force |
Rewrites VkSwapchainCreateInfoKHR::imageFormat to the target order at creation. Adds VK_SWAPCHAIN_CREATE_MUTABLE_FORMAT_BIT + an image-format-list so a view the app makes in its originally-requested format stays valid. |
zero-copy |
Target channel order (env GAMENATIVE_SF_TARGET): rgba8 (default) or bgra8.
force requires the driver to support VK_KHR_swapchain_mutable_format +
VK_KHR_image_format_list (the GameNative wrapper does). If the mutable path
fails, the layer falls back to the unmodified call so you never lose the swapchain.
Requires the Android NDK (Vulkan headers, incl. vulkan/vk_layer.h, ship in its
sysroot). Tested with NDK r27.
# auto-detects ~/Library/Android/sdk/ndk or set ANDROID_NDK explicitly
ANDROID_NDK=/path/to/ndk ./build.shOutput:
dist/arm64-v8a/libVkLayer_surface_format.so
dist/arm64-v8a/VkLayer_surface_format.json
ABI/API are overridable: ABI=arm64-v8a API=26 ./build.sh.
Drop the two files next to the other implicit layers — the same directory that
already holds vkBasalt.json / libbcn_layer.json:
<imagefs>/usr/lib/libVkLayer_surface_format.so
<imagefs>/usr/share/vulkan/implicit_layer.d/VkLayer_surface_format.json
The manifest uses a bare library_path (libVkLayer_surface_format.so, no
path), exactly like vkBasalt.json. The loader resolves a bare filename via the
normal library search path, so the .so must be on it — /usr/lib is. (Do not
use ./name — that resolves relative to the manifest's own directory, so if the
.so isn't sitting in implicit_layer.d/ the loader silently skips the layer.)
If you'd rather be explicit, an absolute library_path like
/usr/lib/libVkLayer_surface_format.so also works.
The layer is inert unless explicitly enabled (manifest enable_environment).
Set these env vars for the guest process:
ENABLE_SURFACE_FORMAT_LAYER=1 # turn the layer on (required)
GAMENATIVE_SF_TARGET=rgba8 # rgba8 (default) | bgra8
GAMENATIVE_SF_MODE=prefer # prefer (default) | filter | force
GAMENATIVE_SF_VERBOSE=1 # optional: log decisions to stderr/logcatTo disable without removing the files: DISABLE_SURFACE_FORMAT_LAYER=1.
In XServerScreen.kt, where the other wrapper env vars are set, drive it from a
container config key (mirroring WRAPPER_RESOURCE_TYPE, etc.):
val surfaceFormat = graphicsDriverConfig.get("surfaceFormat", "BGRA8")
if (!surfaceFormat.equals("BGRA8", true)) {
envVars.put("ENABLE_SURFACE_FORMAT_LAYER", "1")
envVars.put("GAMENATIVE_SF_TARGET", "rgba8")
envVars.put("GAMENATIVE_SF_MODE", "prefer")
}(Ensure the layer files are unpacked into the imagefs for whichever graphics driver the container selects.)
With the Vulkan SDK's vulkaninfo, point the loader at the manifest and confirm
the surface format list changes:
# bare library_path => the loader searches LD_LIBRARY_PATH, so add dist/ to it
VK_ADD_LAYER_PATH=$PWD/dist/arm64-v8a \
LD_LIBRARY_PATH=$PWD/dist/arm64-v8a:$LD_LIBRARY_PATH \
ENABLE_SURFACE_FORMAT_LAYER=1 GAMENATIVE_SF_MODE=filter GAMENATIVE_SF_TARGET=rgba8 \
GAMENATIVE_SF_VERBOSE=1 \
vulkaninfo | grep -A2 -i "surface formats"Two independent signals:
1. The layer's own logcat output. The layer logs via Android liblog, so it
shows in logcat regardless of how the guest's stderr is routed. On every instance
creation it emits one always-on line (no flag needed):
adb logcat -s surface_format_layer
# I/surface_format_layer: ACTIVE: mode=prefer target=rgba8 (intercepting surface formats)
If you see ACTIVE: …, the loader found the manifest, the enable_environment
gate passed, and the layer is in the chain. Add GAMENATIVE_SF_VERBOSE=1 for
per-call detail (each force swap, etc.).
2. The Vulkan loader's discovery log — tells you why a layer is or isn't
loaded (manifest path, library_path resolution, enable_environment result,
ABI mismatch). Set on the guest process:
VK_LOADER_DEBUG=all # or: layer,error,warnThen adb logcat and look for VK_LAYER_GAMENATIVE_surface_format and
"Insert instance layer" / skip-reason lines.
Sanity cross-check: vkBasalt (already shipped in GameNative) loads via the
exact same implicit_layer.d + enable_environment mechanism. If ENABLE_VKBASALT=1
visibly engages but this layer's ACTIVE line never appears, the issue is this
layer's files/manifest, not the loading path.
ENABLE_SURFACE_FORMAT_LAYER=1not set on the guest process (it's gated off by default).- Manifest not in
<imagefs>/usr/share/vulkan/implicit_layer.d/, or itslibrary_pathdoesn't resolve to the.so. - The layer files weren't unpacked into the imagefs for the graphics driver the container actually selected.
- Wrong ABI — the
.somust bearm64-v8a(it is).
Standard implicit-layer dispatch: it chains vkCreateInstance / vkCreateDevice
to capture the next-level vkGetInstanceProcAddr / vkGetDeviceProcAddr, keeps a
small dispatch table keyed by the loader dispatch pointer, and intercepts exactly:
vkGetPhysicalDeviceSurfaceFormatsKHR/…2KHR— reorder/filter (prefer, filter)vkCreateSwapchainKHR— mutable-format override (force)
Everything else passes straight through.
This is structured so a post-processing pass (shaders) can be added at the
vkCreateSwapchainKHR / vkQueuePresentKHR boundary — intercept present, run a
fullscreen pass into the swapchain image, then present. Note that any real shader
effect requires one render pass (it is not zero-copy); the zero-copy property of
this layer applies only to the channel-order fix. For a full ReShade-style pipeline
today, GameNative already ships vkBasalt.
MIT — see LICENSE.