Skip to content

Mobile Platforms

Jeremiah van Oosten edited this page Oct 19, 2026 · 2 revisions

Mobile platforms

vgl's runtime is platform-independent, and Android and iOS are among its targets: Android 7.0 (API 24, 64-bit only) and iOS 15. This page describes the cross-build presets for both, how they are checked in CI, what an integrator must supply on a phone or tablet (an OpenGL ES 3.0 context, a function loader, I/O and tasks), and the current status of each backend on mobile.

Status. In vgl 1.0, the Android and iOS builds of the library, the defaults module, the OpenGL / OpenGL ES backend, the tests and the headless sample are compiled in CI, but nothing is run on a device, emulator or simulator yet, and the repository has no mobile sample app. The OpenGL ES path itself is tested on desktop (Mesa's OpenGL ES, and WebGL 2 in Chrome). Treat mobile as supported by design and by build, not yet verified on hardware.

What works where

Component Android iOS Notes
Core (vgl::vgl) Builds Builds Uses only the C and C++ standard libraries
Defaults module (vgl::defaults) Builds Builds Thread pool, file reader, disk cache. The presets leave the HTTP reader off
OpenGL backend (vgl::gl) Builds Builds The OpenGL ES 3.0 path, the same code that runs on WebGL 2 and Mesa
Direct3D 11 backend No No Windows only
Ogre backend (vgl::ogre) Planned Planned Ogre on OpenGL ES 3 (Android) and Metal (iOS) is milestone M3b, after version 1
Tests, headless sample Built, not run Built, not run Running them on an emulator or simulator is planned (design document, section 12.2)
Interactive samples No No They use GLFW, which is for desktops
Baker No No Windows and Linux only

Graphics on mobile go through OpenGL ES 3.0 for now (design document, section 4). Apple has deprecated OpenGL ES on iOS since iOS 12, but it is still available; the planned long-term Apple path is Ogre's Metal render system, and a raw Metal backend is not planned. OpenGL ES 2.0 is out of scope.

Android

Building with the preset

The android-arm64 preset cross-builds with the NDK's CMake toolchain. Set ANDROID_NDK to an NDK (r27c is tested; CI installs ndk;27.2.12479018) and have Ninja on the path:

export ANDROID_NDK=$ANDROID_HOME/ndk/27.2.12479018
cmake --workflow --preset android-arm64-release

or step by step:

cmake --preset android-arm64
cmake --build --preset android-arm64-release
Setting Value
Toolchain $ANDROID_NDK/build/cmake/android.toolchain.cmake
ANDROID_ABI arm64-v8a
ANDROID_PLATFORM android-24 (Android 7.0)
ANDROID_STL c++_static
BUILD_SHARED_LIBS OFF
VGL_ZSTD_PROVIDER BUNDLED (Zstandard compiled into vgl)

The workflow configures and builds only; the tests are built for Android but not run, since that needs an emulator. 32-bit ARM is not supported (Google Play requires 64-bit code). x86_64, for the emulator, is a target in the design but has no preset; configure with -DANDROID_ABI=x86_64 to try it (not checked in CI).

On Android, a static vgl adds only m to its link interface: the NDK's toolchain links its C++ runtime (libc++) itself.

In an Android Studio project

The usual way is to build vgl with your app's native code: add it with add_subdirectory in the CMake script your module's externalNativeBuild points to. Gradle passes the ABI, platform and STL, and vgl's tests, samples and install rules are off when it is not the top-level project.

# app/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.24)
project(globe LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

set(BUILD_SHARED_LIBS OFF)
set(VGL_ZSTD_PROVIDER BUNDLED)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../../third_party/vgl vgl)

add_library(globe SHARED globe.cpp)
target_link_libraries(globe PRIVATE vgl::vgl vgl::defaults vgl::gl android EGL GLESv3 log)

Pitfalls:

  • vgl needs CMake 3.24 or later. The CMake that Android Studio uses by default may be older; install a newer one through the SDK manager and set externalNativeBuild { cmake { version = "..." } } in the module's Gradle file.
  • Build with the NDK's c++_static or c++_shared STL, as for any C++20 code; vgl needs no other runtime.
  • Packs inside the APK are compressed by default, which makes random access slow. Keep them uncompressed, for example with androidResources { noCompress += listOf("vglm", "vgls") } in the module's Gradle file.

iOS

Building with the preset

The ios-arm64 preset builds on a Mac with Xcode installed and Ninja on the path:

cmake --workflow --preset ios-arm64-release
Setting Value
CMAKE_SYSTEM_NAME iOS
CMAKE_OSX_ARCHITECTURES arm64
CMAKE_OSX_DEPLOYMENT_TARGET 15.0
BUILD_SHARED_LIBS OFF
VGL_ZSTD_PROVIDER BUNDLED

As for Android, the tests are built but not run (that needs a simulator build). There is no simulator preset; a simulator build would set CMAKE_OSX_SYSROOT to iphonesimulator, which CI does not check.

In an Xcode project

Install the static libraries and headers into a prefix and add them to your app target:

cmake --preset ios-arm64
cmake --build --preset ios-arm64-release
cmake --install build/ios-arm64 --config Release --prefix ios-prefix

Link libvgl.a, libvgl-defaults.a and libvgl-gl.a from ios-prefix/lib, add ios-prefix/include to the header search paths, and link the OpenGLES framework. Apple's toolchain links libc++ itself. If your app is built with CMake, find_package(vgl) with CMAKE_PREFIX_PATH pointing at the prefix gives you the same targets as on desktop (Installing vgl).

What the integrator supplies

vgl never creates windows, OpenGL contexts, threads or network connections, and never opens files itself (design document, sections 4 and 7.5). On mobile, as elsewhere, the host provides these through the interfaces described in Host interfaces.

An OpenGL ES 3.0 context

Create an OpenGL ES 3.0 (or later) context and make it current on your render thread before you create the backend:

  • Android: EGL, with EGL_CONTEXT_CLIENT_VERSION 3 (or EGL_CONTEXT_MAJOR_VERSION 3), on the surface of a SurfaceView or GLSurfaceView.
  • iOS: an EAGLContext with kEAGLRenderingAPIOpenGLES3, drawing into a framebuffer object you create, with a color and a depth attachment. iOS has no default framebuffer: bind yours before vgl_gl_backend_draw, which draws into whatever framebuffer is bound.

The backend draws into the host's framebuffer with the host's viewport, and restores the OpenGL state it changes (OpenGL backend).

A function loader

vgl_gl_backend_create_info.get_proc_address is required. The backend links no OpenGL library; it loads every function it uses through your loader, which must also return core OpenGL ES functions (not only extensions). A loader that looks in the GLES library first and falls back to EGL covers both cases on Android:

#include <EGL/egl.h>
#include <dlfcn.h>
#include <vgl/gl.h>

static vgl_gl_proc load_gl(void* user_data, const char* name)
{
    void* gles = user_data; /* dlopen("libGLESv3.so", RTLD_NOW), done once by the host */
    void* f = dlsym(gles, name);
    if (f == NULL)
    {
        f = (void*)eglGetProcAddress(name);
    }
    return (vgl_gl_proc)f;
}

On iOS, the OpenGL ES functions are exported by the OpenGLES framework, so dlsym(RTLD_DEFAULT, name) finds them once the app links it.

Then create the backend on the current context:

vgl_gl_backend_create_info backend_info = {.struct_size = sizeof(backend_info)};
backend_info.get_proc_address = load_gl;
backend_info.get_proc_address_user_data = gles; /* the dlopen handle */
/* depth_convention stays 0: VGL_DEPTH_CONVENTION_LOGARITHMIC. */

vgl_gl_backend* backend = NULL;
vgl_result result = vgl_gl_backend_create(context, &backend_info, &backend);
if (result != VGL_RESULT_SUCCESS)
{
    /* The context's log says why: version, a missing function, or the depth convention. */
}

Depth

Mobile GPUs rarely offer clip control (GL_EXT_clip_control), which reversed-Z needs, so use the default logarithmic convention, VGL_DEPTH_CONVENTION_LOGARITHMIC. It works on every OpenGL ES 3.0 device and gives a usable depth range from orbit to the ground. Clear depth to 1 and compare with LESS, as for any conventional depth buffer (Render interface).

Losing and regaining the OpenGL context

Android destroys the EGL context when an app goes to the background on many devices, and iOS apps may tear down their context too. When that happens:

  1. Do not call vgl_gl_backend_destroy on a dead context. Keep the backend.
  2. Create the new context and make it current.
  3. Call vgl_gl_backend_device_reset(backend). It forgets the old OpenGL objects without deleting them, loads the functions again, rebuilds its shaders, and makes the context offer its render resources again. They are uploaded over the next frames, within the upload budget, while coarser tiles are drawn.

If you set stars or clouds with vgl_gl_backend_stars_set or vgl_gl_backend_clouds_set, the backend keeps its copies and draws them again after the reset. A host with its own renderer calls vgl_context_resources_reset instead (Render interface).

Tasks

Decoding runs on the host's threads through a vgl_task_interface. You have three choices:

  • The defaults module's thread pool (vgl_thread_pool_create, then vgl_thread_pool_task_interface_get). It builds on Android and iOS. Its default thread_count (0) is one less than the hardware threads, which on a phone with big and little cores may be more than you want; set thread_count explicitly, for example 2 to 4.
  • Your engine's job system, behind a vgl_task_interface of your own: submit must run the function exactly once, on any thread, and never block on other tasks.
  • No task interface. vgl_context_update then decodes on the calling thread, within its time_budget (2 ms by default), which keeps frame times predictable on slow devices at the cost of slower loading.

I/O

The context reads packs by URI and byte range through a vgl_io_interface (Host interfaces). It asks for <pack uri>/pack.vglm (the manifest) and ranges of the pack's .vgls shards.

iOS. An app bundle and the app's container are ordinary files, so the defaults module's file reader works: pass absolute paths (UTF-8) to vgl_pack_create, for example the bundle's resource path plus /packs/gebco.

Android. Files in the app's internal or external storage work with the file reader too. Packs inside the APK are not files, so either copy them to storage on first start, or read them with an I/O interface on AAssetManager. A minimal one, which completes each read on the calling thread:

#include <android/asset_manager.h>
#include <stdio.h>
#include <stdlib.h>
#include <vgl/vgl.h>

/* Reads "<uri>" from the APK's assets; the context calls this inside its functions. */
static void asset_read(void* user_data, vgl_io_request* request, const vgl_io_read* read)
{
    AAssetManager* assets = (AAssetManager*)user_data;
    AAsset* asset = AAssetManager_open(assets, read->uri, AASSET_MODE_RANDOM);
    if (asset == NULL)
    {
        vgl_io_request_complete(request, VGL_RESULT_NOT_FOUND, NULL, 0);
        return;
    }

    const uint64_t length = (uint64_t)AAsset_getLength64(asset);
    uint64_t size = 0;
    if (read->offset < length)
    {
        size = length - read->offset;
        if (read->size != VGL_IO_SIZE_TO_END && read->size < size)
        {
            size = read->size;
        }
    }
    if (size == 0)
    {
        /* A range past the end delivers no bytes, as the file reader does. */
        AAsset_close(asset);
        vgl_io_request_complete(request, VGL_RESULT_SUCCESS, NULL, 0);
        return;
    }

    unsigned char* data = malloc((size_t)size);
    uint64_t done = 0;
    if (data != NULL && AAsset_seek64(asset, (off64_t)read->offset, SEEK_SET) >= 0)
    {
        while (done < size)
        {
            const int n = AAsset_read(asset, data + done, (size_t)(size - done));
            if (n <= 0)
            {
                break;
            }
            done += (uint64_t)n;
        }
    }
    AAsset_close(asset);

    if (done == size)
    {
        vgl_io_request_complete(request, VGL_RESULT_SUCCESS, data, (size_t)size);
    }
    else
    {
        vgl_io_request_complete(request, VGL_RESULT_IO_ERROR, NULL, 0);
    }
    free(data); /* vgl copies the data before vgl_io_request_complete returns. */
}

/* At start-up, with the AAssetManager from the activity: */
vgl_io_interface io = {0};
io.version = VGL_IO_INTERFACE_VERSION;
io.user_data = asset_manager;
io.read = asset_read;

vgl_context_create_info context_info = {.struct_size = sizeof(context_info)};
context_info.io = &io;           /* the table is copied; asset_manager must outlive the context */
context_info.tasks = &tasks;     /* from the thread pool, or your own */

Then add packs by their path inside assets/, for example pack_info.uri = "packs/gebco";. This reader blocks the thread that calls into the context while it reads. For large views, hand reads to a worker instead (vgl_io_request_complete may be called from any thread, later), as the defaults module's file reader does with its thread pool.

From a web server. The presets leave VGL_DEFAULTS_HTTP off on mobile, and no mobile build of the libcurl reader is checked in CI. The design's answer for hosts with a network layer of their own (OkHttp or HttpURLConnection on Android, NSURLSession on iOS) is to implement the I/O interface on it: range requests for byte ranges, VGL_RESULT_NOT_FOUND for 404 and 410, an empty success for 416. The defaults module's disk cache (vgl_disk_cache) can sit in front of such an interface to keep what was read for offline use, since it wraps any vgl_io_interface (it caches http:// and https:// URIs and passes others straight through). See Hosting packs for what servers must support.

Memory budgets

Phones have less memory than desktops. Both budgets are set when the context is created:

context_info.cpu_budget = 128ull * 1024 * 1024; /* decoded tiles and pack metadata (0: 256 MiB) */
context_info.gpu_budget = 256ull * 1024 * 1024; /* render resources (0: 512 MiB) */

and the upload budget per frame is the upload_budget of vgl_gl_draw_info, which spreads uploads over frames after a large view change. vgl_context_statistics_get reports what is in use, to tune them (Context API).

See also

Clone this wiki locally