Skip to content

Host Interfaces

Jeremiah van Oosten edited this page Oct 18, 2026 · 1 revision

Host interfaces

The vgl core never allocates from a heap of its own choosing, never prints, never opens a file or a socket and never starts a thread. Instead, the host (your application or engine) hands it four extension points when it creates a context: an allocator, a log callback, an I/O interface and a task interface. This page explains what each one is for, the exact contract the core relies on, how the core calls them, and how to write your own, with complete examples such as an I/O reader over a game's asset archive and a task adapter for an existing job system. If you only need files, HTTP and a thread pool, the Defaults module implements the I/O and task interfaces for you; come back here when you want to replace them.

The declarations live in include/vgl/foundation.h and include/vgl/context.h. The rules they follow are in the C API conventions, sections 9 to 13, and the design behind them is in section 7.5 of the design document. For the same interfaces written as C++ classes, see C++ wrapper.

Overview

All four are passed in vgl_context_create_info, and all four are optional:

Field Type Without it
allocator const vgl_allocator_interface* The context uses the C heap.
log, log_user_data, log_level vgl_log_fn, void*, vgl_log_level The context is silent.
io const vgl_io_interface* vgl_pack_create fails with VGL_RESULT_INVALID_OPERATION: no packs can be read.
tasks const vgl_task_interface* vgl_context_update does the decoding work itself, within its time budget.
vgl_context_create_info info = {.struct_size = sizeof(info)};
info.allocator = &my_allocator;   /* optional */
info.log = my_log;                /* optional */
info.log_user_data = NULL;
info.log_level = VGL_LOG_LEVEL_INFO;
info.io = &my_io;                 /* needed to read packs */
info.tasks = &my_tasks;           /* optional */

vgl_context* context = NULL;
vgl_result r = vgl_context_create(&info, &context);
if (VGL_FAILED(r))
{
    const char* name = NULL;
    vgl_result_name_get(r, &name);
    fprintf(stderr, "vgl_context_create: %s\n", name);
}

In C, a designated initializer such as {.struct_size = sizeof(info)} zero-initializes every other field, including the padding fields that VGL_POINTER_PADDING adds on 32-bit and wasm32 targets. In C++, use vgl::init<vgl_context_create_info>() instead (see C++ wrapper).

Rules that apply to all of them

  • Tables are copied. vgl_context_create copies the vgl_allocator_interface, vgl_io_interface and vgl_task_interface tables, so they may live on the stack. The user_data pointers inside them are kept, so the objects they point at must stay valid until the context is destroyed. The allocator must also outlive every outstanding request and task of the context (see Destroying a context).
  • Versions. Each interface table starts with uint32_t version, which you set to the matching macro: VGL_ALLOCATOR_INTERFACE_VERSION, VGL_IO_INTERFACE_VERSION or VGL_TASK_INTERFACE_VERSION (all 1 today). An unknown version makes vgl_context_create fail with VGL_RESULT_VERSION_MISMATCH. Leave reserved at 0.
  • Required entries. A null allocate, deallocate, read or submit makes creation fail with VGL_RESULT_INVALID_ARGUMENT. The entries marked optional (cancel, both destroy entries) may be null.
  • No exceptions, no longjmp. A callback must not let a C++ exception or a longjmp escape into the library (C API conventions, section 9.3).
  • No re-entry. A callback must not call back into the library on the same context, except for functions documented as allowed from callbacks: in practice, vgl_io_request_complete.
  • Thread safety. The allocator and the log callback can be called from any thread once a task interface is installed, or once your I/O completes requests on other threads. Make them thread-safe in that case.

Allocator

#define VGL_ALLOCATOR_INTERFACE_VERSION 1

typedef struct vgl_allocator_interface
{
    uint32_t version; /* VGL_ALLOCATOR_INTERFACE_VERSION */
    uint32_t reserved;
    void* user_data;
    void* (*allocate)(void* user_data, size_t size, size_t alignment);
    void (*deallocate)(void* user_data, void* memory, size_t size, size_t alignment);
} vgl_allocator_interface;

Every allocation made on behalf of a context goes through this table: pack metadata, decoded tiles, meshes, textures, I/O requests, the copies of the data your I/O delivers, and the allocations of the decoders (Zstandard and stb_image are routed to it too). Functions that take no context, such as the geodesy functions, do not allocate.

Entry Contract
allocate Returns size bytes aligned to alignment, or null on failure. size is never 0. alignment is always a power of two, and can be smaller than sizeof(void*).
deallocate Frees memory returned by allocate. It receives the same size and alignment that were passed to allocate, so a sized or pool allocator does not need a header. memory is never null. The entry is named deallocate rather than free because the Microsoft debug CRT defines free as a macro.

What happens on failure. When allocate returns null, the operation that needed the memory fails with VGL_RESULT_OUT_OF_MEMORY. For a function you call, that is its return value. For background work, the effect is local: a tile that cannot be read or decoded for lack of memory is logged as a warning and read again when it is next needed, a read that cannot start makes height and ray queries return VGL_RESULT_NOT_READY (with a log message), and vgl_io_request_complete returns VGL_RESULT_OUT_OF_MEMORY when it cannot copy your data, which fails that read. The library never terminates the process, and the context stays usable (C API conventions, section 6.7). An allocator that enforces a hard limit is therefore a legitimate way to cap vgl's memory, though the CPU and GPU budgets of the context (cpu_budget, gpu_budget) are the intended controls.

Thread safety. The allocator is "any thread": with a task interface installed, decode, mesh and texture tasks allocate on your task threads, and completions you deliver from other threads allocate there. It must be thread-safe whenever either is the case.

Lifetime. The allocator must outlive the context and everything the context left outstanding. A read your I/O completes after the context is destroyed, or a task your job system runs late, frees its memory through this allocator.

Without an allocator, the context uses the C heap through _aligned_malloc on Windows and posix_memalign elsewhere. Libraries that work on behalf of a context, such as the backends, get the context's allocator with vgl_context_allocator_get, so that their memory reaches yours as well. The defaults module takes no context, so each of its objects takes an allocator of its own in its create info (see Defaults module).

Example: a counting allocator in C

#include <vgl/vgl.h>

#include <stdlib.h>
#if defined(_WIN32)
#    include <malloc.h>
#endif

/* Counts the bytes vgl holds. Single-threaded as written: guard the counter with an atomic or
 * a mutex if you install a task interface or complete reads on other threads. */
typedef struct counting_allocator
{
    size_t bytes;
    size_t peak;
} counting_allocator;

static void* counting_allocate(void* user_data, size_t size, size_t alignment)
{
    counting_allocator* self = (counting_allocator*)user_data;
    void* memory = NULL;
    /* posix_memalign needs at least the alignment of a pointer. */
    if (alignment < sizeof(void*))
    {
        alignment = sizeof(void*);
    }
#if defined(_WIN32)
    memory = _aligned_malloc(size, alignment);
#else
    if (posix_memalign(&memory, alignment, size) != 0)
    {
        memory = NULL;
    }
#endif
    if (memory != NULL)
    {
        self->bytes += size;
        if (self->bytes > self->peak)
        {
            self->peak = self->bytes;
        }
    }
    return memory;
}

static void counting_deallocate(void* user_data, void* memory, size_t size, size_t alignment)
{
    counting_allocator* self = (counting_allocator*)user_data;
    (void)alignment;
    self->bytes -= size;
#if defined(_WIN32)
    _aligned_free(memory);
#else
    free(memory);
#endif
}

/* ... */
static counting_allocator counter; /* must outlive the context */
vgl_allocator_interface allocator = {0};
allocator.version = VGL_ALLOCATOR_INTERFACE_VERSION;
allocator.user_data = &counter;
allocator.allocate = counting_allocate;
allocator.deallocate = counting_deallocate;
info.allocator = &allocator; /* copied by vgl_context_create */

Example: forwarding to an engine allocator in C++

Derive from vgl::allocator and pass c_interface(). Both functions must be noexcept.

#include <vgl/vgl.hpp>

#include <atomic>

class engine_allocator final : public vgl::allocator
{
public:
    void* allocate(std::size_t size, std::size_t alignment) noexcept override
    {
        void* memory = engine::heap_alloc_aligned(size, alignment); // your engine's allocator
        if (memory != nullptr)
        {
            bytes_.fetch_add(size, std::memory_order_relaxed);
        }
        return memory;
    }

    void deallocate(void* memory, std::size_t size, std::size_t /*alignment*/) noexcept override
    {
        bytes_.fetch_sub(size, std::memory_order_relaxed);
        engine::heap_free_aligned(memory);
    }

    [[nodiscard]] std::size_t bytes() const noexcept { return bytes_.load(); }

private:
    std::atomic<std::size_t> bytes_{0};
};

engine_allocator memory; // outlives the context
const vgl_allocator_interface table = memory.c_interface();
auto info = vgl::init<vgl_context_create_info>();
info.allocator = &table;

Logging

typedef void (*vgl_log_fn)(void* user_data, vgl_log_level level, const char* message);
Level Value Meaning
VGL_LOG_LEVEL_DEFAULT 0 Only as log_level in the create info, where it selects VGL_LOG_LEVEL_WARNING. Never the level of a message.
VGL_LOG_LEVEL_ERROR 1 Something failed: a pack could not be read or parsed, a tile is corrupt, an allocation failed.
VGL_LOG_LEVEL_WARNING 2 Something is wrong but vgl carries on, for example a pack left in a context when it is destroyed.
VGL_LOG_LEVEL_INFO 3 Notable events.
VGL_LOG_LEVEL_DEBUG 4 Detail for diagnosing problems.
  • Set the callback, its user data and the minimum level in vgl_context_create_info (log, log_user_data, log_level). A message is passed on when its level is at or below the minimum (ERROR is the most severe and lowest value). A log_level outside 0 to 4 makes vgl_context_create fail with VGL_RESULT_INVALID_ARGUMENT.
  • Without a callback, the library is silent: it never writes to standard output, standard error or a debugger itself.
  • message is UTF-8, has no trailing newline, and is valid only during the call: copy it if you queue it for later. Messages start with the name of the API function involved, say what happened and, where the fix is known, how to fix it. For example, a pack whose manifest cannot be read logs vgl_context_update: pack <uri>: reading pack.vglm failed; check the pack's URI (VGL_RESULT_NOT_FOUND).
  • The callback is "any thread": make it thread-safe if a task interface is installed.
  • The level and callback are fixed for the life of the context. To change verbosity at run time, set log_level to VGL_LOG_LEVEL_DEBUG and filter in your callback.
  • Libraries that work on behalf of a context, such as the backends, log through vgl_context_log, so their messages arrive at the same callback with the same filtering. You can use it too, to put your own messages in the same stream.
static void log_message(void* user_data, vgl_log_level level, const char* message)
{
    static const char* const names[] = {"", "error", "warning", "info", "debug"};
    (void)user_data;
    fprintf(stderr, "vgl %s: %s\n", (level >= 1 && level <= 4) ? names[level] : "?", message);
}

info.log = log_message;
info.log_user_data = NULL;
info.log_level = VGL_LOG_LEVEL_INFO;

When you report a failed pack to the user, vgl_pack_state_get gives the result code (for example VGL_RESULT_NOT_FOUND or VGL_RESULT_CORRUPT_DATA) and the log has the details.

I/O interface

The core reads packs by asking the host for byte ranges of files. It does not know whether the bytes come from a disk, an archive, an Android APK, an iOS bundle, a web server or memory. That is what makes it portable (design document, section 4).

#define VGL_IO_INTERFACE_VERSION 1

typedef struct vgl_io_read
{
    const char* uri;   /* UTF-8, null-terminated; valid until the request is completed */
    VGL_POINTER_PADDING(uri_padding)
    uint64_t offset;   /* first byte to read */
    uint64_t size;     /* bytes to read, or VGL_IO_SIZE_TO_END */
} vgl_io_read;

typedef struct vgl_io_interface
{
    uint32_t version; /* VGL_IO_INTERFACE_VERSION */
    uint32_t reserved;
    void* user_data;
    void (*read)(void* user_data, vgl_io_request* request, const vgl_io_read* read);
    void (*cancel)(void* user_data, vgl_io_request* request);  /* optional */
    void (*destroy)(void* user_data);                          /* optional */
} vgl_io_interface;

vgl_result vgl_io_request_complete(vgl_io_request* request, vgl_result result,
                                   const void* data, size_t size);

What the core asks for

A pack is a directory (see Pack format overview). When you call vgl_pack_create with a uri, every read the pack causes has a vgl_io_read.uri made of that string, a / (unless it already ends in one) and a path relative to the pack:

File Typical read
pack.vglm The whole manifest: offset 0, size = VGL_IO_SIZE_TO_END. Started inside vgl_pack_create.
base-<digest>.vgls, shards/<y>/<x>-<digest>.vgls First the head of the shard (offset 0, 64 KiB), then, if needed, the rest of the tile directory, then individual tile ranges.

So a pack created with the URI archive:terrain/gebco produces reads of archive:terrain/gebco/pack.vglm, archive:terrain/gebco/base-1a2b3c4d.vgls and so on. The URI is opaque to the core: it never parses a scheme, so you can invent your own (archive:, apk:, mem:) and route on it. The core does not percent-decode or normalize anything.

Reads start inside context functions on the thread that calls them: vgl_pack_create (the manifest), vgl_context_update (shards and tiles for the view), and the height and ray queries (vgl_context_height_query, vgl_context_ray_query), which start reading the finer data they need.

The contract

  1. read starts a read. It is called on the calling thread, inside the context's functions. It may complete the request before it returns (synchronous I/O) or later from any thread (asynchronous I/O). It must not block for long if it is called on your main thread, since it runs inside vgl_context_update.

  2. Complete exactly once. Every request must be completed with vgl_io_request_complete, exactly once, also when the read cannot even start (complete it with an error then). A request that is never completed leaks its memory and leaves its tile loading forever.

  3. What to deliver. On success pass VGL_RESULT_SUCCESS with the bytes. Deliver exactly size bytes starting at offset, or fewer only where the file ends. For VGL_IO_SIZE_TO_END, deliver everything from offset to the end. A range that starts at or past the end of the file is a success with 0 bytes (data may then be null). On failure pass an error and data = NULL, size = 0:

    Situation Result
    The file does not exist VGL_RESULT_NOT_FOUND
    Reading failed, timed out, or the server answered with an error VGL_RESULT_IO_ERROR
    You gave up a read after cancel VGL_RESULT_CANCELLED
    You could not allocate a buffer VGL_RESULT_OUT_OF_MEMORY

    Positive results (VGL_RESULT_INCOMPLETE, VGL_RESULT_NOT_READY) mean nothing for a read and are rejected: the request then fails with VGL_RESULT_INVALID_ARGUMENT, as it does when data is null with a non-zero size.

  4. The data is copied. vgl_io_request_complete copies data before it returns, so you may free or reuse your buffer right after the call. It returns VGL_RESULT_SUCCESS when the completion was queued, or VGL_RESULT_OUT_OF_MEMORY if the copy could not be allocated (the read then fails with that result).

  5. The handle is consumed. Every call with a non-null request consumes it, whatever the call returns. Do not touch the handle afterwards and never complete it twice. vgl_io_request_complete(NULL, ...) does nothing and returns VGL_RESULT_INVALID_ARGUMENT.

  6. read is valid until completion. The vgl_io_read pointer and its uri stay valid until you complete the request, so you do not need to copy them for an asynchronous read, as long as you complete the request before you stop using them.

  7. Completion is free-threaded and only queues. vgl_io_request_complete may be called from any thread, concurrently, including from inside read. It only queues the result: the context processes it during the next vgl_context_update. Data you complete is therefore not visible to height queries until an update has run.

Cancellation

cancel is optional. The core calls it, on the calling thread, when it no longer needs a read: today when a pack is destroyed (for the pack's outstanding reads) and when the context is destroyed (for all of them). It is a hint. The host must still complete the request, either normally or with VGL_RESULT_CANCELLED, now or later; the completion is then discarded. If your reads are short (a local file), leaving cancel null is fine: the reads simply finish. Implement it when reads can be slow, such as network reads, so that destroying a pack does not wait for downloads nobody needs.

destroy

destroy is optional and is called once, on the thread that calls vgl_context_destroy, while the context is destroyed. Requests may still be outstanding at that point, and you must still complete them, now or later. If you free your I/O state in destroy, complete what you hold first. If one I/O object serves several contexts, destroy is called once per context, because each context has its own copy of the table. The defaults module's interfaces have no destroy callback: their objects must outlive every context that uses them.

Example: a synchronous reader for a game's asset archive (C)

Games often ship their data inside a single archive file. The reader below serves packs from such an archive. The archive API is your engine's, shown here as three hypothetical functions; everything else is vgl's real API. Packs are added with URIs such as archive:terrain/gebco.

#include <vgl/vgl.h>

#include <stdlib.h>
#include <string.h>

/* Your engine's archive API (hypothetical). */
typedef struct game_archive game_archive;
/* Finds an entry; returns 0 if it does not exist. */
int game_archive_find(game_archive* archive, const char* path, uint64_t* out_offset,
                      uint64_t* out_size);
/* Reads bytes of the archive file; returns 0 on failure. Thread-safe. */
int game_archive_read(game_archive* archive, uint64_t offset, void* buffer, size_t size);

static const char archive_prefix[] = "archive:";

static void archive_read(void* user_data, vgl_io_request* request, const vgl_io_read* read)
{
    game_archive* archive = (game_archive*)user_data;
    const size_t prefix_length = sizeof(archive_prefix) - 1;
    uint64_t entry_offset = 0;
    uint64_t entry_size = 0;
    uint64_t offset;
    uint64_t size;
    void* buffer;

    if (strncmp(read->uri, archive_prefix, prefix_length) != 0 ||
        !game_archive_find(archive, read->uri + prefix_length, &entry_offset, &entry_size))
    {
        vgl_io_request_complete(request, VGL_RESULT_NOT_FOUND, NULL, 0);
        return;
    }

    /* Clamp the range to the entry: fewer bytes only where the file ends. */
    offset = read->offset < entry_size ? read->offset : entry_size;
    size = read->size < entry_size - offset ? read->size : entry_size - offset;
    if (size == 0)
    {
        vgl_io_request_complete(request, VGL_RESULT_SUCCESS, NULL, 0);
        return;
    }
    if (size > SIZE_MAX)
    {
        vgl_io_request_complete(request, VGL_RESULT_OUT_OF_MEMORY, NULL, 0);
        return;
    }

    buffer = malloc((size_t)size);
    if (buffer == NULL)
    {
        vgl_io_request_complete(request, VGL_RESULT_OUT_OF_MEMORY, NULL, 0);
        return;
    }
    if (!game_archive_read(archive, entry_offset + offset, buffer, (size_t)size))
    {
        free(buffer);
        vgl_io_request_complete(request, VGL_RESULT_IO_ERROR, NULL, 0);
        return;
    }
    /* vgl copies the bytes before this returns. */
    vgl_io_request_complete(request, VGL_RESULT_SUCCESS, buffer, (size_t)size);
    free(buffer);
}

/* ... */
vgl_io_interface io = {0};
io.version = VGL_IO_INTERFACE_VERSION;
io.user_data = archive;     /* must outlive the context */
io.read = archive_read;     /* cancel and destroy stay null */
info.io = &io;

vgl_pack_create_info pack_info = {.struct_size = sizeof(pack_info)};
pack_info.uri = "archive:terrain/gebco";
vgl_pack* gebco = NULL;
vgl_pack_create(context, &pack_info, &gebco);

This reader is synchronous: each read runs inside vgl_context_update on your main thread. That is fine for an archive on a fast local disk (the default file reader without a pool works the same way), but reads add to the time of the update. To keep the main thread free, hand the reads to a worker, as below. The same pattern serves Android APK assets (AAsset_seek64 and AAsset_read) and iOS bundles.

Example: an asynchronous reader in C++

vgl::io_interface turns the C table into a class with read, cancel and on_destroy. This reader queues reads for one worker thread of its own (the host may create threads; only the core does not), supports cancellation, and completes from the worker.

#include <vgl/vgl.hpp>

#include <condition_variable>
#include <cstddef>
#include <deque>
#include <mutex>
#include <string>
#include <thread>
#include <vector>

class archive_reader final : public vgl::io_interface
{
public:
    explicit archive_reader(game_archive* archive) : archive_(archive), worker_([this] { run(); }) {}

    // Destroy after every context that reads through this object.
    ~archive_reader() override
    {
        {
            std::lock_guard lock(mutex_);
            stopping_ = true;
        }
        wake_.notify_one();
        worker_.join(); // run() completes what is left before it returns
    }

    void read(vgl::io_request* request, const vgl::io_read& read) noexcept override
    {
        // read.uri stays valid until completion, so the pointer can be kept as it is.
        {
            std::lock_guard lock(mutex_);
            queue_.push_back({request, read.uri, read.offset, read.size, false});
        }
        wake_.notify_one();
    }

    void cancel(vgl::io_request* request) noexcept override
    {
        std::lock_guard lock(mutex_);
        for (job& j : queue_)
        {
            if (j.request == request)
            {
                j.cancelled = true; // still completed, with result::cancelled
            }
        }
    }

private:
    struct job
    {
        vgl::io_request* request;
        const char* uri;
        std::uint64_t offset;
        std::uint64_t size;
        bool cancelled;
    };

    void run() noexcept
    {
        for (;;)
        {
            job j{};
            {
                std::unique_lock lock(mutex_);
                wake_.wait(lock, [this] { return stopping_ || !queue_.empty(); });
                if (queue_.empty())
                {
                    return; // stopping, and every request has been completed
                }
                j = queue_.front();
                queue_.pop_front();
            }
            if (j.cancelled)
            {
                static_cast<void>(vgl::io_request_complete(j.request, vgl::result::cancelled));
                continue;
            }
            complete_from_archive(j);
        }
    }

    void complete_from_archive(const job& j) noexcept
    {
        // Strip "archive:", find the entry and clamp the range, as in the C example.
        // ... on a missing entry:
        //     vgl::io_request_complete(j.request, vgl::result::not_found);
        // ... on success, with the bytes in buffer_ (reused, since vgl copies them):
        //     vgl::io_request_complete(j.request, vgl::result::success,
        //                              std::as_bytes(std::span(buffer_)));
    }

    game_archive* archive_;
    std::vector<std::byte> buffer_;
    std::mutex mutex_;
    std::condition_variable wake_;
    std::deque<job> queue_;
    bool stopping_ = false;
    std::thread worker_; // last, so that it starts after the other members
};

archive_reader reader(archive);
const vgl_io_interface io = reader.c_interface();
auto info = vgl::init<vgl_context_create_info>();
info.io = &io;

Because this reader completes on its worker thread, the context's allocator and log callback must be thread-safe (the defaults are). std::deque::push_back can throw std::bad_alloc; in a build with exceptions enabled, catch it in read and complete the request with vgl::result::out_of_memory so that nothing escapes the noexcept function.

Chaining I/O interfaces

An I/O interface can pass a read on to another one and handle what comes back, which is how the defaults module's disk cache sits in front of the HTTP reader. Retries, logging, statistics, decryption or a cache of your own can be added the same way, without changes to the core (design document, section 7.5).

The tool for it is vgl_io_request_create, which makes a request whose completion calls your callback instead of reaching a context:

typedef void (*vgl_io_complete_fn)(void* user_data, vgl_result result,
                                   const void* data, size_t size);

typedef struct vgl_io_request_create_info
{
    uint32_t struct_size;
    uint32_t reserved;
    vgl_io_complete_fn complete;                   /* required */
    VGL_POINTER_PADDING(complete_padding)
    void* user_data;
    VGL_POINTER_PADDING(user_data_padding)
    const vgl_allocator_interface* allocator;      /* optional; null uses the C heap */
    VGL_POINTER_PADDING(allocator_padding)
} vgl_io_request_create_info;

vgl_result vgl_io_request_create(const vgl_io_request_create_info* info,
                                 vgl_io_request** out_request);
  • Pass the new request to the inner interface's read with a vgl_io_read you keep valid until it completes. When you wrap a read for a context, the context's own vgl_io_read qualifies: it stays valid until you complete the outer request, which you do only after the inner one completed.
  • Whoever completes the inner request calls vgl_io_request_complete on it, as usual. That call invokes your complete callback on the completing thread, with the result and, on success, the bytes, which are valid only during the callback. Then it frees the request.
  • A request that never reaches a read (because you decided not to forward it after all) is freed by completing it yourself.
  • allocator is optional. If the inner interface completes on another thread, it must be thread-safe.
  • vgl_io_request_create is free-threaded. It returns VGL_RESULT_INVALID_ARGUMENT for a null pointer, a missing complete callback or an invalid allocator, VGL_RESULT_VERSION_MISMATCH for an allocator of unknown version, VGL_RESULT_NOT_SUPPORTED when struct_size is from a newer version, and VGL_RESULT_OUT_OF_MEMORY.

Example: a retrying wrapper (C)

This wrapper sits between a context and any other I/O interface, and tries a failed read up to three times before giving up. It retries from the thread that delivered the failure, so the inner interface must accept reads from any thread (the defaults module's HTTP reader and file reader both do).

#include <vgl/vgl.h>

#include <stdlib.h>

typedef struct retry_io
{
    vgl_io_interface inner; /* the interface that does the reading */
} retry_io;

typedef struct retry_op
{
    retry_io* self;
    vgl_io_request* outer;  /* the context's request */
    const vgl_io_read* read; /* the context's read, valid until outer completes */
    int attempts;
} retry_op;

static void retry_start(retry_op* op);

static void retry_complete(void* user_data, vgl_result result, const void* data, size_t size)
{
    retry_op* op = (retry_op*)user_data;
    if (result == VGL_RESULT_IO_ERROR && op->attempts < 3)
    {
        retry_start(op); /* the inner request is already freed; make another */
        return;
    }
    /* Hand the result to the context. data is valid only during this call; vgl copies it. */
    vgl_io_request_complete(op->outer, result, data, size);
    free(op);
}

static void retry_start(retry_op* op)
{
    vgl_io_request_create_info info = {.struct_size = sizeof(info)};
    vgl_io_request* inner = NULL;
    info.complete = retry_complete;
    info.user_data = op;
    ++op->attempts;
    if (vgl_io_request_create(&info, &inner) != VGL_RESULT_SUCCESS)
    {
        vgl_io_request_complete(op->outer, VGL_RESULT_OUT_OF_MEMORY, NULL, 0);
        free(op);
        return;
    }
    op->self->inner.read(op->self->inner.user_data, inner, op->read);
}

static void retry_read(void* user_data, vgl_io_request* request, const vgl_io_read* read)
{
    retry_op* op = (retry_op*)malloc(sizeof(retry_op));
    if (op == NULL)
    {
        vgl_io_request_complete(request, VGL_RESULT_OUT_OF_MEMORY, NULL, 0);
        return;
    }
    op->self = (retry_io*)user_data;
    op->outer = request;
    op->read = read;
    op->attempts = 0;
    retry_start(op);
}

/* ... with http_io from vgl_http_reader_io_interface_get, or any interface: */
static retry_io retry;  /* must outlive the context */
retry.inner = http_io;
vgl_io_interface io = {0};
io.version = VGL_IO_INTERFACE_VERSION;
io.user_data = &retry;
io.read = retry_read;
info.io = &io;

The wrapper has no cancel, so a cancelled read runs to completion (with its retries) and is then discarded. To forward cancels, keep a map from outer to inner requests under a mutex, and call inner.cancel for the inner request that is current. Remember that a cancelled inner read may still complete normally.

In C++, vgl::io_request_create(complete, user_data, allocator) returns a vgl::expected<vgl::io_request*> for the same purpose.

Task interface

#define VGL_TASK_INTERFACE_VERSION 1

typedef void (*vgl_task_fn)(void* user_data);

typedef struct vgl_task_interface
{
    uint32_t version; /* VGL_TASK_INTERFACE_VERSION */
    uint32_t reserved;
    void* user_data;
    vgl_result (*submit)(void* user_data, vgl_task_fn function, void* task_data);
    void (*destroy)(void* user_data); /* optional */
} vgl_task_interface;

How the core submits work

During vgl_context_update, the core submits three kinds of CPU work through submit:

  • Tile decoding: decompressing (Zstandard) and dequantizing elevation tiles read from a pack.
  • Mesh building: turning decoded heights into the terrain meshes of the render interface.
  • Texture decoding: decoding imagery tiles (JPEG or PNG through stb_image) into textures with mip levels.

Each task is a function pointer and a pointer to its data. The tasks are independent: they never wait on each other or on the host, so you may run them in any order, concurrently, on any number of threads. Results are handed back to the context through an internal queue and picked up by the next vgl_context_update.

What the host must guarantee

Guarantee Detail
Run exactly once When submit returns VGL_RESULT_SUCCESS, call function(task_data) exactly once, on any thread, now or later. Running it inside submit is allowed.
Or decline Return any other result (for example VGL_RESULT_OUT_OF_MEMORY when your queue is full) and never call function. The core then does the work itself, inside vgl_context_update. Declining is always safe; it only costs main-thread time.
Run even after destruction A task you accepted must still run if the context has been destroyed meanwhile. It notices and returns without doing work, freeing its memory. Dropping it leaks that memory.
Thread-safe dependencies Tasks allocate through the context's allocator and may log, on your task threads. Both must be thread-safe.
No exceptions Tasks do not throw; your wrapper must not either.
Outlive the context The object behind user_data must stay valid until the context is destroyed, and your threads must keep running until every accepted task has run.

submit itself is called on the calling thread, inside vgl_context_update. destroy, if set, is called once on the thread that calls vgl_context_destroy; tasks may still be queued then, and you must still run them, now or later. vgl_context_destroy waits for tasks that are already running, but never for queued ones.

Without a task interface, vgl_context_update decodes within its time budget (vgl_update_info.time_budget, 2 ms by default; at least one decode runs if any is waiting). That is a good choice for tools, tests and single-threaded hosts. With a task interface, the update spends its time on bookkeeping and the decoding happens in parallel.

Example: an adapter for an existing job system (C)

#include <vgl/vgl.h>

/* Your engine's job system (hypothetical): runs fn(data) on a worker, returns 0 when it cannot
 * take the job. */
typedef struct job_system job_system;
int job_system_push(job_system* jobs, void (*fn)(void*), void* data);

static vgl_result submit_to_jobs(void* user_data, vgl_task_fn function, void* task_data)
{
    job_system* jobs = (job_system*)user_data;
    /* vgl_task_fn and the job function have the same signature, so no wrapper is needed. */
    return job_system_push(jobs, function, task_data) ? VGL_RESULT_SUCCESS
                                                      : VGL_RESULT_OUT_OF_MEMORY;
}

/* ... */
vgl_task_interface tasks = {0};
tasks.version = VGL_TASK_INTERFACE_VERSION;
tasks.user_data = engine_jobs;   /* must outlive the context */
tasks.submit = submit_to_jobs;
info.tasks = &tasks;

If your job system takes a different job type, allocate a small record holding function and task_data, push it, and free it in the job before or after calling function(task_data).

When you shut down, destroy the vgl context before you stop the job system, and let the job system drain its queue (rather than discarding jobs) when it stops.

Example: a task adapter in C++

#include <vgl/vgl.hpp>

class engine_tasks final : public vgl::task_interface
{
public:
    explicit engine_tasks(engine::job_system& jobs) noexcept : jobs_(jobs) {}

    vgl::result submit(vgl::task_fn function, void* task_data) noexcept override
    {
        // Low priority, so that vgl's decoding does not delay the frame's own jobs.
        const bool accepted = jobs_.try_push(engine::priority::low, function, task_data);
        return accepted ? vgl::result::success : vgl::result::out_of_memory;
    }

    void on_destroy() noexcept override
    {
        // Called once per context that used this object, while it is destroyed. Queued
        // tasks must still run; nothing to do here unless you count contexts.
    }

private:
    engine::job_system& jobs_;
};

engine_tasks tasks(jobs);
const vgl_task_interface task_table = tasks.c_interface();
auto info = vgl::init<vgl_context_create_info>();
info.tasks = &task_table;

To use the defaults module's thread pool instead, see Defaults module.

Threading summary

Call Thread Notes
vgl_io_interface.read, .cancel The thread calling into the context Inside vgl_pack_create, vgl_context_update, queries, vgl_pack_destroy and vgl_context_destroy.
vgl_io_request_complete Any thread Free-threaded; may be called inside read. Only queues the result.
vgl_io_complete_fn (chained requests) The thread that completes the inner request Inside vgl_io_request_complete.
vgl_task_interface.submit The thread calling vgl_context_update
The task functions Your task threads Possibly concurrently with each other.
destroy entries The thread calling vgl_context_destroy Once per context.
Allocator, log callback Any thread Thread-safe once tasks or cross-thread completions are in use.

The context functions themselves are context-synchronized: calls on one context (or its packs) must not overlap, but you may make them from any thread, one at a time. The frame functions of the render interface must also not overlap with vgl_context_update (see Render interface and Core concepts).

Destroying a context with work outstanding

A context may be destroyed while reads are outstanding and tasks are queued. You do not need to drain your queues first (design document, section 7.5):

  1. vgl_context_destroy destroys the packs still in the context, with a warning for each (which cancels their reads, if the I/O interface has cancel).
  2. It stops taking completions and waits for tasks that are running right now.
  3. If the I/O interface has cancel, it is called for every read still outstanding.
  4. io.destroy and then tasks.destroy are called, if set.
  5. The context is freed. The state that outstanding requests and queued tasks need is reference counted and survives it.
  6. When you complete a request or run a task later, it finds the context gone and frees itself, through the context's allocator.

So the order of teardown in the host is: destroy the context (its packs go with it, with a warning each, or destroy them first), then the I/O and task objects, then the allocator, which must be last. The defaults module adds its own order: context, then disk cache, HTTP reader and file reader, then the thread pool (see Defaults module).

Pitfalls

  • Completing a request twice, or never. Both are bugs. Count your completions in debug builds, including the error paths and the path where cancel arrives.
  • Assuming completion is immediate. Data you complete is processed at the next vgl_context_update. A pack's state stays VGL_PACK_STATE_LOADING until an update has run, even with synchronous I/O.
  • Using read->uri after completing. It is freed with the request.
  • Non-thread-safe allocator or log with tasks. The symptoms are heap corruption or garbled logs under load. With a task interface, both are called from your worker threads.
  • Dropping queued tasks at shutdown. Leaks memory; run them instead, they return quickly.
  • Throwing from a callback. C++ callbacks must be noexcept in effect. The C++ base classes declare their virtual functions noexcept.
  • posix_memalign with a small alignment. vgl may ask for an alignment of 1 or 2; raise it to at least sizeof(void*) before calling posix_memalign.
  • Calling into the context from a callback. Only vgl_io_request_complete (and vgl_io_request_create) may be called from inside read.

See also

  • Defaults module: ready-made thread pool, file reader, HTTP reader and disk cache.
  • C++ wrapper: vgl::allocator, vgl::io_interface, vgl::task_interface.
  • Context API: packs, updates, queries and statistics (reads_outstanding, tiles_decoding).
  • Mobile platforms: reading packs from Android assets and iOS bundles.
  • Hosting packs: what a web server must do for vgl's range reads.

Clone this wiki locally