Repository navigation
Core Concepts
This page explains how vgl is put together, for developers who embed it in their own application or engine. It covers who owns what (the host, the core, the backends and the defaults module), the data vgl reads (packs, layers, tiles and levels of detail), the WGS84 ellipsoid and the coordinate frames, the depth conventions, the CPU and GPU budgets, what happens in each frame, the threading model, results and logging, and how versioned structures keep the ABI stable. The specifications in the repository are the source of truth and are linked throughout: the design document, the C API conventions, the render interface and the pack format.
vgl is a library that cooperates with an application, not a framework that takes it over. The host (your application) owns:
- the window and the graphics device or context,
- the main loop and its timing,
- the camera, which it passes to vgl in double precision each frame,
- the render target, including clearing it, its depth buffer and presenting it.
Each frame, the host sets the view, calls vgl_context_update, and then asks a backend to draw
the globe into its current render pass. vgl never clears, swaps buffers or presents, and it
draws together with whatever else the host draws, depth-tested against it (see
depth conventions).
The library also follows a set of rules that make it predictable to embed:
| Rule | What it means for you |
|---|---|
| The C API is the ABI | One binary serves C and C++. The C++ wrapper is header-only and adds no second ABI. |
| No exceptions across the API | Every function returns a vgl_result. The library builds with exceptions and RTTI off (VGL_DISABLE_EXCEPTIONS). |
| No global state | All state lives in a vgl_context. Several contexts are independent. |
| No threads, no network connections | The core creates neither. You supply task scheduling and I/O, or use the defaults module that does. |
| All memory through your allocator | Every allocation on behalf of a context goes through the allocator you give it (or the C heap). |
| Bounded memory | Decoded data and GPU resources stay within budgets you set. |
| Portable core | The core uses only the C and C++ standard libraries: no operating system calls, no std::filesystem or std::thread. |
flowchart TB
Host["Host application"]
Core["vgl core (vgl::vgl)<br/>context, packs, selection, cache,<br/>decoding, meshes, queries"]
Defaults["Defaults module (vgl::defaults)<br/>thread pool, file reader,<br/>HTTP reader, disk cache"]
RI["Render interface<br/>(vgl/render.h)"]
GL["OpenGL / ES backend (vgl::gl)"]
DX["Direct3D 11 backend (vgl::d3d11)"]
OG["Ogre backend (vgl::ogre)"]
Own["Host's own renderer"]
Packs[("Packs on disk or a web server")]
Host --> Core
Host -. optional .-> Defaults
Defaults -- "task and I/O interfaces" --> Core
Defaults --> Packs
Core --> RI
RI --> GL
RI --> DX
RI --> OG
RI --> Own
| Piece | Library and header | Role |
|---|---|---|
| Core |
vgl::vgl, <vgl/vgl.h> (and <vgl/vgl.hpp>) |
Turns packs into GPU-ready data: reads and decodes tiles, selects them for the view, builds meshes and textures, answers height and ray queries. It calls no graphics API. |
| Render interface | Part of the core, <vgl/render.h>
|
The contract between the core and whatever draws: per-frame lists of resources to create and destroy, and a draw list. See Render Interface. |
| Backends |
vgl::gl (<vgl/gl.h>), vgl::d3d11 (<vgl/d3d11.h>), vgl::ogre (<vgl/ogre.hpp>) |
Consumers of the render interface that draw the globe with a graphics API or engine. Each is a library of its own, built on the public C API only, with its headers in backends/<backend>/include/vgl. |
| Defaults module |
vgl::defaults, <vgl/defaults.h>
|
Optional ready-made implementations of the task and I/O interfaces: a thread pool, a file reader, an HTTP reader (with VGL_DEFAULTS_HTTP) and a disk cache. Unlike the core, it creates threads and opens files and connections. |
The backends are the OpenGL / OpenGL ES backend (OpenGL 3.3 core or OpenGL ES 3.0, and WebGL 2 in a browser), the Direct3D 11 backend (feature level 11_0) and the Ogre backend (classic Ogre 14, on its OpenGL 3+ and Direct3D 11 render systems on Windows and Linux; Vulkan, macOS and the mobile platforms follow after version 1, as milestone M3b). A host with its own renderer uses the render interface directly, exactly as the built-in backends do.
All data reaches the runtime as packs, made offline by the Baker. A pack is a
directory with a small manifest (pack.vglm) and one or more range-readable shard files
(base-<digest>.vgls, shards/<y>/<x>-<digest>.vgls). The runtime only ever reads byte
ranges of these files, so a pack can sit on a local disk or on any web server that serves HTTP
range requests. The format is specified in
pack-format.md; the
Pack Format Overview summarizes it.
There are three kinds of pack, recorded in the manifest's layer_type. You do not say which
kind you add; the pack's manifest does:
| Kind | Holds | What the context does with it |
|---|---|---|
| Elevation | Heights above the ellipsoid, as 129 x 129 sample grids per tile | Shapes the terrain; answers height and ray queries |
| Imagery | Images, 256-pixel tiles with a 1-pixel border by default (JPEG, or PNG where there is alpha) | One imagery layer, blended over the layers of lower priority |
| Geoid | The geoid height (EGM2008) on the same grid as elevation | Converts heights to mean sea level; places the optional sea surface. At most one per context. |
The project hosts no data. You bake the elevation and imagery you need and host the packs
yourself (Hosting Packs); the repository's samples/data holds a small
sample set for the samples and tests.
Several packs of the same kind stack by priority (vgl_pack_create_info::priority), so
that a regional high-resolution pack refines a global base without rebaking:
- Where packs overlap, the one with the higher priority wins where it has data. Among equal priorities, the pack created later wins.
- Where the winning elevation pack has nodata, the next one fills in, so regional packs blend into the base without holes.
- Each imagery pack is a separate layer. Layers draw bottom (lowest priority) to top, each
blended by its image's alpha times an opacity the host sets (
vgl_pack_opacity_set). At opacity 0 a layer is hidden: neither loaded nor drawn nor credited. - Priority and opacity can change at run time. See Context API.
Terrain and imagery share one geographic (latitude and longitude) quadtree:
- Level 0 has two tiles of 180 by 180 degrees: (0, 0, 0) is the western hemisphere and (0, 1, 0) the eastern. Each level splits every tile into four.
- Column x counts east from the antimeridian, row y south from the north pole. Level L has 2L+1 columns and 2L rows.
- With 129-sample elevation tiles, level 8 has about 611 m between samples at the equator, level 12 about 38 m (the deepest level the test bake chose for Copernicus GLO-30).
Pyramids are sparse: fine levels exist only where a pack has data, and each tile records which children exist and their height range, so the runtime can cull tiles before loading them. Geodesy and Tiling covers the scheme in detail.
Selection picks the tiles to draw by screen-space error: a tile's geometric error (its
sample spacing) projected to pixels. A tile is refined when that exceeds
vgl_view::max_screen_space_error, 2 pixels by default. Tiles outside the frustum or below the
horizon are culled. Detail arrives level by level: a tile is refined only once its own data has
loaded, an unloaded child is drawn from its nearest loaded ancestor, and a newly selected tile
gets a small placeholder mesh at once, so no part of the globe is ever empty while it loads.
Regions no pack covers draw as the bare ellipsoid. The details are in the design document,
section 7.3.
| Frame | Definition | Where it appears |
|---|---|---|
| Geodetic | Latitude and longitude in radians, height in metres above the ellipsoid (vgl_geodetic) |
Height queries, ray hits, places |
| ECEF | Earth-centred, Earth-fixed, right-handed, metres: origin at the ellipsoid's centre, +Z through the north pole, +X through latitude 0, longitude 0 | The view matrix, ray queries, projection of positions, draw items' centre
|
| ENU | Local East-North-Up at a point (vgl_ellipsoid_enu_frame_get) |
Placing a camera or a vehicle on the ground |
| Camera space | Whatever the host's view matrix maps ECEF to, in any handedness | The model-view matrices of the draw list |
- The reference ellipsoid is WGS84 unless the context is created with another
(
vgl_context_create_info::ellipsoid); packs must use the same one. - All heights are ellipsoidal unless the name says otherwise. Heights above mean sea level need
the geoid pack (
VGL_HEIGHT_REFERENCE_MEAN_SEA_LEVEL); the geoid and the ellipsoid differ by up to about 100 m. - Angles are radians everywhere.
VGL_DEGREES_TO_RADIANS,VGL_RADIANS_TO_DEGREES,vgl_degrees_to_radiansandvgl_radians_to_degreesconvert. - Matrices (
vgl_dmat4) are column-major and multiply column vectors: element (row r, column c) ism[c * 4 + r], translation inm[12]tom[14], as in GLM and OpenGL. A host with row-major, row-vector matrices, common with Direct3D math libraries, passes its matrices unchanged: the two differences cancel out.
The Earth's radius is about 6.4 million metres, and a float has about seven significant
digits, so single-precision ECEF positions would jitter by metres near the ground. vgl avoids
this (requirement NFR-09, no visible jitter at any distance):
- Every position and distance that crosses the API is
double, including the camera. - Mesh vertices are
floats relative to each tile's centre, so their values stay small. - Each tile's model-view matrix (
view * translate(centre)) is composed on the CPU in double precision and rounded tofloatonly at the end. The GPU never sees large coordinates.
Hosts drawing their own geometry next to the globe should do the same: compose model-view
matrices in double precision relative to the camera, and convert to float last.
Geodesy and Tiling has the conversion functions.
A globe seen from orbit down to the ground needs an enormous depth range. The host owns the
depth buffer and the projection, so it chooses the convention when it creates a backend
(vgl_depth_convention, in <vgl/render.h>). Every backend supports all three:
| Convention | Value | Projection | Depth buffer | Use it |
|---|---|---|---|---|
| Logarithmic (default) | VGL_DEPTH_CONVENTION_LOGARITHMIC |
The host's, conventional; near and far planes do not matter | Any, cleared to 1, compare LESS; the shader writes depth from the camera distance |
Everywhere, including OpenGL ES, WebGL 2 and macOS |
| Reversed-Z | VGL_DEPTH_CONVENTION_REVERSED_Z |
Reversed: near to 1, far (ideally infinite) to 0, in a depth range 0 to 1 | 32-bit float, cleared to 0, compare GREATER
|
Direct3D 11; OpenGL 4.5, GL_ARB_clip_control or GL_EXT_clip_control
|
| Conventional | VGL_DEPTH_CONVENTION_CONVENTIONAL |
The host's as it is | Any, cleared to 1, compare LESS
|
Small scenes only: distant terrain z-fights |
The logarithmic convention writes log2(1 + w) / log2(1 + far) as window depth, with w the
distance along the view axis and far the far distance passed when drawing
(VGL_LOGARITHMIC_DEPTH_FAR_DEFAULT, 1.0e8 m, when the host passes none). Host geometry that
must depth-test against the globe, such as an aircraft on the runway, writes the same depth. In
GLSL, with p the camera-space position:
// log_depth_coefficient = 2.0 / log2(far + 1.0)
// Vertex shader:
v_log_depth = 1.0 + max(-p.z, 0.0);
gl_Position.z = (log2(v_log_depth) * log_depth_coefficient - 1.0) * gl_Position.w;
// Fragment shader:
gl_FragDepth = log2(v_log_depth) * log_depth_coefficient * 0.5;Reversed-Z gives the best precision where it is available. A backend asked for a convention the
device cannot do fails to create with VGL_RESULT_NOT_SUPPORTED. Section 8 of the
render interface
has the details.
Memory is bounded by budgets set when the context is created (requirement NFR-04). A zero value selects the default:
| Budget | Field | Default | What it bounds |
|---|---|---|---|
| CPU | vgl_context_create_info::cpu_budget |
256 MiB | Decoded tiles, encoded imagery tiles, pack metadata and meshes not yet uploaded |
| GPU | vgl_context_create_info::gpu_budget |
512 MiB | Meshes and textures the consumer was asked to create |
| Update time | vgl_update_info::time_budget |
2 ms | Decoding inside vgl_context_update when there is no task interface |
| Upload |
vgl_gl_draw_info::upload_budget (and the other backends' equivalents, vgl_frame_begin_info::upload_budget) |
16 MiB (VGL_UPLOAD_BUDGET_DEFAULT) |
Bytes of new resources per frame, so a big view change spreads its uploads over several frames |
How they work:
- Over the CPU budget, decoded tiles and meshes are evicted least recently used, but never those the current view needs.
- Once a mesh or texture is uploaded, its CPU copy is freed and it counts against the GPU budget instead. Over the GPU budget, the core destroys resources not drawn in the current frame, least recently drawn first. Resources that selected tiles still wait for are exempt.
- The consumer never decides what to evict: it only applies the destroy list it is given.
- Lost GPU resources (a lost device or OpenGL context) are rebuilt from the decoded tiles after
vgl_context_resources_reset, which the backends'device_resetfunctions call.
vgl_context_statistics_get reports use against both budgets
(Context API). On mobile devices, smaller budgets than the defaults
are usually right.
sequenceDiagram
participant H as Host
participant C as vgl context
participant T as Tasks and I/O (host or defaults)
participant B as Backend
H->>C: vgl_context_view_set(view)
H->>C: vgl_context_update()
C->>C: take completed reads and decodes
C->>C: select tiles, request missing ones
C->>T: read byte ranges, submit decodes and mesh builds
T-->>C: vgl_io_request_complete, task done (any thread)
H->>B: draw (render thread)
B->>C: vgl_context_frame_begin
C-->>B: destroys, creates (within the upload budget), draw list
B->>B: apply resources, draw tiles
B->>C: vgl_frame_end
In order, each update:
- Takes completions. Reads the host completed and tasks that finished since the last update are processed in full: manifests parsed, shard directories indexed, tiles queued for decoding.
-
Decodes. Without a task interface, it decodes and builds meshes itself, within
time_budget(at least one job runs if any is waiting). With one, this work runs on the host's threads and the update only collects the results. -
Selects. For the view set with
vgl_context_view_set, it selects tiles by screen-space error, with frustum and horizon culling, and builds placeholders for new tiles. - Requests. It requests the tiles selection needs that are not loaded, coarser levels first, then larger errors first, with at most 64 reads outstanding.
- Evicts decoded data beyond the CPU budget.
stateDiagram-v2
[*] --> Unloaded
Unloaded --> Requested: selected
Requested --> Loading: I/O issued
Loading --> Decoded: data decoded, mesh built
Decoded --> Uploaded: created by the consumer, CPU copy freed
Uploaded --> Decoded: device lost (vgl_context_resources_reset)
Uploaded --> Evicted: over GPU budget, least recently drawn
Decoded --> Evicted: over CPU budget
Evicted --> Unloaded
Loading --> Unloaded: cancelled
A tile's data is read in a few steps: the pack's manifest once, then the directory of the shard that holds the tile (usually one range request), then the tile's own byte range. Imagery tiles stay encoded in the cache, a few kilobytes each, and are decoded into RGBA8 textures with a full mip chain on a task thread when a selected tile wants them.
The backend's draw function (for example vgl_gl_backend_draw) brackets a frame with
vgl_context_frame_begin and vgl_frame_end. The frame lists what to destroy, then the index
lists, meshes and textures to create (within the upload budget), then the tiles to draw,
nearest first, each with its float model-view matrix and imagery layers. A host with its own
renderer walks the same lists itself (Render Interface).
The update and the frame must not overlap. A host that updates on one thread and renders on another serializes the two.
The library creates no threads. Every function documents one of these categories (C API conventions, section 11):
| Category | Rule | Examples |
|---|---|---|
| Free-threaded | Any thread, concurrently with anything |
vgl_context_create, vgl_context_allocator_get, vgl_context_log, vgl_view_project, vgl_view_geodetic_project, the geodesy functions, vgl_io_request_complete, vgl_io_request_create, vgl_result_name_get, vgl_version_get
|
| Context-synchronized | Calls on one context, or on objects created from it, must not overlap; any thread, one at a time |
vgl_context_update, vgl_context_view_set, vgl_pack_create, the queries, vgl_context_statistics_get, vgl_context_destroy
|
| Render thread | Context-synchronized, and on the thread that owns the graphics device |
vgl_context_frame_begin and the frame functions, vgl_context_resources_reset, every backend function |
| Externally synchronized | For objects of no context, such as the defaults module's: calls on one object must not overlap |
vgl_thread_pool_destroy, vgl_file_reader_destroy
|
Callbacks the library calls:
| Callback | Where it runs |
|---|---|
vgl_io_interface.read, .cancel, .destroy
|
The calling thread, inside a context function (usually vgl_context_update or a query) |
vgl_task_interface.submit, .destroy
|
The calling thread, inside a context function |
| The task function you are given | A task thread of yours, possibly concurrently with others; tasks never block on each other or on the host |
vgl_log_fn and the allocator |
Any thread: they must be thread-safe if a task interface is installed |
Tasks. With a task interface, decoding and mesh building run on the host's threads, and
the update just collects results. Without one (tasks null), the update does that work
itself, within its time budget, which suits a single-threaded host (NFR-10). If submit
returns anything but VGL_RESULT_SUCCESS, the library does the work itself.
I/O. The library asks for byte ranges by URI; the host completes each request exactly once
with vgl_io_request_complete, from any thread, immediately or later. Completions only queue
the result; it takes effect at the next update. A context may be destroyed with reads
outstanding or tasks queued: late completions find the context gone and free themselves, and
the host never has to drain its queues first. Host Interfaces covers writing
your own; the Defaults Module provides ready ones.
Every function returns vgl_result: zero is success, positive values are statuses that still
succeeded, negative values are errors.
| Code | Value | Meaning |
|---|---|---|
VGL_RESULT_SUCCESS |
0 | Succeeded |
VGL_RESULT_INCOMPLETE |
1 | Succeeded, but an output array was too small |
VGL_RESULT_NOT_READY |
2 | Succeeded, but finer data is still loading; the output holds the best answer so far |
VGL_RESULT_INVALID_ARGUMENT |
-1 | An argument is null, out of range or otherwise invalid |
VGL_RESULT_OUT_OF_MEMORY |
-2 | An allocation failed, or a budget does not allow it |
VGL_RESULT_NOT_SUPPORTED |
-3 | Valid, but not supported by this build, backend or device; or a structure uses fields of a newer version |
VGL_RESULT_INVALID_OPERATION |
-4 | Not allowed in the object's current state |
VGL_RESULT_NOT_FOUND |
-5 | An item, file or tile does not exist |
VGL_RESULT_IO_ERROR |
-6 | Reading failed |
VGL_RESULT_CORRUPT_DATA |
-7 | Data was read but failed validation or decoding |
VGL_RESULT_VERSION_MISMATCH |
-8 | A pack, interface or structure version this binary does not support |
VGL_RESULT_CANCELLED |
-9 | An asynchronous operation was cancelled |
VGL_RESULT_BACKEND_ERROR |
-10 | A graphics API or engine call failed |
VGL_RESULT_INTERNAL_ERROR |
-11 | A bug in vgl; please report it |
- Test with
VGL_SUCCEEDED(r)andVGL_FAILED(r)rather than comparing withVGL_RESULT_SUCCESS, so thatVGL_RESULT_NOT_READYcounts as success. Treat unknown negative codes as failures and unknown positive codes as success. -
vgl_result_name_getgives the name, such as"VGL_RESULT_OUT_OF_MEMORY". - A failed function leaves its objects unchanged, and the library stays usable after any failure, including running out of memory.
There is no "last error" state. The details go to the log callback
(vgl_context_create_info::log, with log_user_data): every failure of a function that has a
context logs one error message that names the function, says what was wrong and, where known,
how to fix it. Without a callback the library is silent; it never prints by itself. The minimum
level is vgl_context_create_info::log_level; VGL_LOG_LEVEL_DEFAULT selects warnings.
VGL_LOG_LEVEL_INFO is useful while integrating. Backends log through the same callback
(vgl_context_log).
Structures that may grow start with uint32_t struct_size, which the caller sets to sizeof
the structure. Every other field has a default selected by its zero value. So the idiom is:
zero the structure, set struct_size, then set only what you need.
vgl_context_create_info info = {.struct_size = sizeof(info)}; /* Every other field zero. */
info.cpu_budget = 128ull * 1024 * 1024;auto info = vgl::init<vgl_context_create_info>(); // Zeroed, with struct_size set.
info.cpu_budget = 128ull * 1024 * 1024;- Always zero the whole structure. On 32-bit targets, including WebAssembly, each pointer in a
structure with 64-bit fields is followed by a padding field (
VGL_POINTER_PADDING), so code that sets fields one by one on an uninitialized structure, or a designated initializer that names every field in order, can miss one. Zeroing first is always correct. -
Input structures. A smaller
struct_size(a host built against older headers) gives the missing fields their defaults. A larger one is accepted if the unknown bytes are zero, and fails withVGL_RESULT_NOT_SUPPORTEDotherwise. -
Output structures (
vgl_context_statistics,vgl_ray_hit,vgl_viewfromvgl_context_view_get): setstruct_size, at least 8. The library writes the fields that fit and setsstruct_sizeto the bytes it wrote. -
Interfaces (
vgl_io_interface,vgl_task_interface,vgl_allocator_interface) carry aversionfield instead, set to theVGL_<NAME>_INTERFACE_VERSIONmacro. The library copies the table; itsuser_datamust stay valid until the context is destroyed.
From version 1.0, a host built against headers X.Y runs with any binary X.Z where Z >= Y.
Breaking changes come only with a new major version, and the
changelog lists them. vgl_version_get reports the
binary's version, and VGL_VERSION_MAJOR, VGL_VERSION_MINOR, VGL_VERSION_PATCH and
VGL_VERSION_STRING the headers'.
- Objects are opaque handles with create and destroy functions. Destroying null does nothing.
- Objects belong to their parent: destroying a context destroys its remaining packs, with a warning for each, since that usually means a leak. Destroy children first.
- A backend must be destroyed before its context, on the render thread, with its graphics context current. Destroying the context destroys none of the consumer's GPU objects.
- Host interfaces must outlive the context that uses them: destroy the context, then the defaults module's readers and cache, then the thread pool.
- Strings the library returns (attribution lines, result names) are owned by the library, and each function says how long they stay valid.
- Getting Started: a first integration.
- Context API: every context function in detail.
- Host Interfaces and Defaults Module: tasks, I/O, allocators and logging.
- Render Interface: drawing the visible set yourself.
- Geodesy and Tiling: conversions and the tile scheme.
-
Cpp Wrapper: RAII handles and
vgl::expected.
Getting vgl
Using the library
Rendering
Data
Reference
Specifications