Skip to content

Render Interface

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

Render interface

This page is for integrators who draw the globe themselves, on the vgl core alone, without the built-in OpenGL, Direct3D 11 or Ogre backends: a host engine with its own renderer, a Vulkan or Metal renderer, or a scene graph that wants the tiles as its own objects. It explains the frame API in vgl/render.h (and its C++ wrapper vgl/render.hpp), the resource lists, the draw list, the vertex, sample and texture formats, the transforms, the depth conventions, the shader math a renderer needs, and ends with a step-by-step skeleton of a custom backend. The specification is docs/render-interface.md; this page summarizes it and links to it, and the specification wins where they differ.

The interface was reviewed before version 1, and stays compatible within it: its structures grow without breaking consumers (see strides).

Contents

When to use it

You want to Use
Draw the globe into an OpenGL 3.3, OpenGL ES 3.0 or WebGL 2 context The OpenGL backend
Draw into a Direct3D 11 device The Direct3D 11 backend
Put the globe in a classic Ogre 14 scene The Ogre backend
Draw with another API (Vulkan, Metal, Direct3D 12, WebGPU), or with your engine's own materials, shadows or deferred lighting This interface
Count or inspect what would be drawn, without a GPU This interface, as the headless sample does

The built-in backends are consumers of exactly this API, implemented against the public C headers only, so a custom renderer goes down the same tested path. Their sources are the best reference implementation: backends/gl/backend.cpp in particular. The headless sample is a minimal consumer without a GPU.

Rules of the interface

  • Pulled, not pushed. The core never calls into your renderer. Each frame you call vgl_context_frame_begin, walk the lists it returns, and call vgl_frame_end. There are no callbacks, so there are no re-entrancy rules: you may call any vgl function while you walk the lists.
  • Every frame. Call vgl_context_frame_begin and vgl_frame_end every frame you draw, even when the view did not change. Destroys and uploads only happen inside a frame.
  • Render thread. The frame functions are in the render-thread category of the C API conventions (section 11): context-synchronized, on the thread that owns your graphics device.
  • Not during an update. A frame must not overlap vgl_context_update. If you update on one thread and render on another, serialize the two. vgl_context_update returns VGL_RESULT_INVALID_OPERATION while a frame is open, and vgl_context_frame_begin returns it if a frame is already open.
  • One consumer per context. A context has at most one consumer of the render interface at a time: do not run a built-in backend and your own renderer on the same context. If you switch consumers, call vgl_context_resources_reset (the built-in backends do this when destroyed) so that the next one receives every resource again.
  • Views into library memory. The lists point into the context's memory and are valid until vgl_frame_end. Copy what you need to keep.
  • No allocations once warm. The frame's lists keep their capacity, so frames allocate only while the lists grow towards the size of the view, and not at all after that.
  • Your objects are yours. Destroying the context destroys none of your GPU objects; free them yourself.

See Core Concepts and Context API for the context, packs, the view and updates.

Frame flow

Per frame, on the render thread:

  1. Set the view (vgl_context_view_set) and call vgl_context_update. These select the tiles and drive loading; they can run on another thread, as long as they do not overlap the frame.
  2. vgl_context_frame_begin returns a vgl_frame*.
  3. Apply the resource lists, in this order:
    1. vgl_frame_destroys_get: destroy these resources first, so memory is freed before anything new is created.
    2. vgl_frame_index_lists_get: create index lists.
    3. vgl_frame_meshes_get: create meshes. A mesh's index list was created in this frame or an earlier one.
    4. vgl_frame_textures_get: create textures. While creating, you may call vgl_frame_resource_handle_set (your handle comes back in draw items) or vgl_frame_resource_failed (the resource could not be created).
  4. Read the draw list (vgl_frame_draw_list_get) and its layers (vgl_frame_draw_layers_get) after the creates, and draw. Items may draw resources created in this very frame, and carry the handles you just set.
  5. vgl_frame_end. Every resource offered in the frame and not reported as failed now counts as created, and the core frees its CPU copy.
sequenceDiagram
    participant H as Host
    participant C as vgl context
    participant R as Your renderer
    H->>C: vgl_context_view_set(view)
    H->>C: vgl_context_update()
    H->>R: draw (render thread)
    R->>C: vgl_context_frame_begin(info)
    C-->>R: frame: resource lists, draw list
    Note over R: destroy, then create<br/>(index lists, meshes, textures)
    Note over R: draw the list
    R->>C: vgl_frame_end(frame)
Loading

Without a view, a frame draws nothing but still offers destroys of what is no longer needed.

Beginning a frame

vgl_frame_begin_info is a versioned input structure: zero-initialize it, set struct_size, then fill in what you need. info may also be null for all defaults.

Field Meaning 0 or null selects
struct_size sizeof(vgl_frame_begin_info) Required
draw_item_size The size of vgl_draw_item in your header: the stride of the draw list This header's size
draw_layer_size The size of vgl_draw_layer: the stride of the layer list This header's size
index_list_data_size The size of vgl_index_list_data This header's size
mesh_data_size The size of vgl_mesh_data This header's size
texture_data_size The size of vgl_texture_data This header's size
upload_budget Bytes of resources you may create in this frame VGL_UPLOAD_BUDGET_DEFAULT (16 MiB)
view_matrix Optional const vgl_dmat4*: the camera to compose model-view matrices for, when it differs from the view of the last update The view set with vgl_context_view_set

view_matrix exists for hosts that update at a lower rate than they draw (for example updates at 20 Hz and draws at 60 Hz), or that draw from a camera other than the one tiles were selected for (the samples' "freeze selection" debug mode does this).

vgl_context_frame_begin returns VGL_RESULT_INVALID_ARGUMENT for a null pointer or invalid info, VGL_RESULT_INVALID_OPERATION if a frame is already open, VGL_RESULT_NOT_SUPPORTED if info has fields of a newer vgl set, and VGL_RESULT_OUT_OF_MEMORY if its lists could not grow.

vgl_frame_begin_info info = {0};
info.struct_size = sizeof(info);
info.upload_budget = 8u * 1024 * 1024; /* spread uploads more thinly than the default */

vgl_frame* frame = NULL;
if (vgl_context_frame_begin(context, &info, &frame) != VGL_RESULT_SUCCESS)
{
    return; /* the context's log says why */
}
/* ... apply the lists, draw ... */
vgl_frame_end(frame);

In C++, vgl::frame ends the frame in its destructor, so an early return cannot leave it open:

auto info = vgl::init<vgl_frame_begin_info>();
vgl::expected<vgl::frame> frame = vgl::frame::begin(context, &info);
if (!frame)
{
    return; // frame.error() is a vgl::result
}
// frame->destroys_get(), frame->meshes_get(), ... as std::span; the frame ends at scope exit.

Strides and growth

The lists are arrays in the core's memory, written at the strides you state in vgl_frame_begin_info. Fields are only ever appended to these structures, so:

  • a consumer built against an older header states its smaller sizes (or 0) and the core writes only the fields it knows;
  • a consumer built against a newer header states its larger sizes and gets zeros in the fields this core does not have.

No stride below the 1.0 sizes is accepted. With 0 or sizeof(...) of your header, plain array indexing works. The C++ wrapper's spans assume its own header's sizes, so vgl::frame::begin rejects other strides with vgl::result::invalid_argument; read such lists through the C API and step by your stride:

const vgl_draw_item* items = NULL;
uint32_t count = 0;
vgl_frame_draw_list_get(frame, &items, &count);
for (uint32_t i = 0; i < count; ++i)
{
    const vgl_draw_item* item =
        (const vgl_draw_item*)((const char*)items + (size_t)i * info.draw_item_size);
    /* ... */
}

Resources

Kinds and identity

Kind Contents Shared
Index list The triangle list of one grid size Yes: by every mesh of that grid size
Mesh A tile's vertices, and the full-resolution samples it was built from No: one per tile and data source
Texture One imagery tile, in a negotiated format, with its mip chain Yes: by every tile drawn with that imagery tile, its own or an ancestor's

Each resource has a 64-bit identifier, unique within the context and never reused. Map identifiers to your own objects (a hash map), or set a handle with vgl_frame_resource_handle_set(frame, resource, handle): any 64-bit value, typically a pointer to your object, which the core echoes in later draw items (mesh_handle, index_list_handle, vgl_draw_layer.texture_handle). An unset handle is 0. The built-in OpenGL backend sets the address of its record as the handle for meshes and textures and falls back to a map lookup when a handle is 0.

Resource list structures

Structure Key fields
vgl_index_list_data resource, indices (pointer), index_count, index_size (2 or 4)
vgl_mesh_data resource, index_list (the index list's identifier), vertices, vertex_count, vertex_size (VGL_TERRAIN_VERTEX_SIZE), tile (vgl_tile_id: level, x, y), centre (ECEF origin of the vertex positions), min_height, max_height, samples, sample_grid, sample_size (VGL_TERRAIN_SAMPLE_SIZE)
vgl_texture_data resource, format (vgl_texture_format), width, height, mip_count, mip_data (one pointer per level, largest first), mip_sizes (bytes per level)

Every pointer in these structures is followed by a VGL_POINTER_PADDING field, so the layouts are identical on 32-bit, wasm32 and 64-bit targets. Never fill these structures yourself with positional initializers; you only read them.

Failures

If you cannot create a resource (out of GPU memory, an unsupported format), call vgl_frame_resource_failed(frame, resource, reason) with an error result such as VGL_RESULT_OUT_OF_MEMORY. The core logs it, does not draw it, and offers it again in a later frame. If an index list fails, the meshes that use it fail too: report them as well (the OpenGL backend reports such a mesh with VGL_RESULT_NOT_FOUND). A draw item whose mesh or index list you could not create in that frame must be skipped.

Upload budget and GPU budget

  • Upload budget. A frame offers creates up to upload_budget bytes, so a large view change spreads its uploads over several frames. At least one resource is always offered, so a large one cannot stall forever. Placeholders (a few kilobytes each) come first and outside the budget, then meshes of tiles that have nothing but a placeholder, then the rest, coarsest first.
  • GPU budget. vgl_context_create_info.gpu_budget (0 selects 512 MiB) bounds the bytes of resources the core asks you to keep. When the total exceeds it, the core destroys meshes and textures not drawn in the current frame, least recently drawn first, through the destroy list. You never decide what to evict. Index lists are small and stay until a reset.
  • No holes. A region keeps what it drew last frame until everything that replaces it is resident, so you never see holes while finer tiles stream in.

vgl_context_statistics_get reports the GPU memory in use (gpu_used), tiles drawn and resources created; see Context API.

CPU copies and device loss

After vgl_frame_end, the core frees the CPU copy of every created mesh and texture; only your GPU copy remains. If you lose your GPU objects (a removed Direct3D device, a destroyed OpenGL context, an Android app sent to the background), call:

vgl_context_resources_reset(context); /* not while a frame is open */

The core forgets every resource and offers them again over the next frames, within the upload budget, rebuilt from the decoded tiles (and decoded again from cached data where those were evicted). Coarser tiles draw in the meantime.

Meshes: vertices, samples and index lists

Vertex layout

Fixed for the major version, and described by constants in vgl/render.h:

Offset constant Offset Attribute Format
VGL_TERRAIN_VERTEX_POSITION_OFFSET 0 Position relative to the mesh's centre, along the ECEF axes, in metres 3 x float
VGL_TERRAIN_VERTEX_HEIGHT_OFFSET 12 Height above the ellipsoid, in metres float
VGL_TERRAIN_VERTEX_UV_OFFSET 16 Texture coordinates over the tile, u east, v south 2 x unsigned 16-bit normalized
VGL_TERRAIN_VERTEX_NORMAL_OFFSET 20 Normal, octahedral encoding, along the ECEF axes 2 x signed 16-bit normalized

The stride is VGL_TERRAIN_VERTEX_SIZE (24 bytes). These formats exist in OpenGL ES 3.0 (GL_FLOAT, GL_UNSIGNED_SHORT normalized, GL_SHORT normalized), Direct3D 11 (R32G32B32_FLOAT, R32_FLOAT, R16G16_UNORM, R16G16_SNORM), Vulkan and Metal, and Ogre 14 (VET_FLOAT3, VET_FLOAT1, VET_USHORT2_NORM, VET_SHORT2_NORM).

Grid and skirts

A mesh is a square grid of g x g vertices, rows from the north, with texture coordinates (i, j) / (g - 1), followed by 4 (g - 1) skirt vertices that hang below the tile's edges with the texture coordinates of the edge vertices above them. Skirts hide cracks between neighbouring tiles of different levels. The vertex count n = g² + 4 (g - 1) gives the grid size back, which a wireframe or grid overlay needs:

// (g + 2)^2 = n + 8
const auto g = static_cast<std::uint32_t>(std::lround(std::sqrt(8.0 + vertex_count) - 2.0));

Skirt vertices have heights below the mesh's min_height, which is how a shader tells them apart (the sea surface needs that, see Shader math).

Samples

A mesh's grid can be coarser than the data it was built from (where the coarser surface stays within about a pixel), which cuts the triangle count of views from orbit dramatically. Its samples are that data at full resolution, for shading: sample_grid x sample_grid samples, rows from the north, each from the west, at texture coordinates (i, j) / (sample_grid - 1). Every vertex of the grid lies on a sample.

Offset constant Offset Field Format
VGL_TERRAIN_SAMPLE_HEIGHT_OFFSET 0 Height above the ellipsoid float
VGL_TERRAIN_SAMPLE_NORMAL_OFFSET 4 Normal, octahedral encoding 2 x signed 16-bit normalized

Each sample is VGL_TERRAIN_SAMPLE_SIZE (8 bytes). Shade from the samples, interpolated bilinearly over the texture coordinates, rather than from the vertices: height colours, contours, the coastline of a sea surface and lighting then keep full detail however coarse the grid. Shading from the vertices is correct but coarser. How you store them is your choice:

Backend Storage
OpenGL GL_RG32UI texture, the bytes as they are; the shader reads the height's bits with uintBitsToFloat and unpacks the normal's halves, and interpolates by hand (integer textures do not filter)
Direct3D 11 DXGI_FORMAT_R32G32_UINT texture, read the same way
Ogre A 32-bit float two-channel texture, converted on upload, because Ogre's Direct3D 11 render system has no integer textures

Index lists

Indices are 16-bit (index_size 2) when the mesh has at most 65,536 vertices (grids up to 254 x 254 with their skirts) and 32-bit above that. Draw items repeat index_count and index_size. Triangles are counter-clockwise seen from outside the ellipsoid, in right-handed ECEF. With a right-handed camera space and a standard projection, counter-clockwise is the front face in clip space. If your projection mirrors the image (a flipped y axis for rendering into a texture, for example), cull the other side.

16-bit lists may contain index 65,535 as an ordinary vertex: switch primitive restart off while drawing (WebGL always restarts at 0xFFFF, but no grid size reaches exactly 65,536 vertices, so it never matters there).

Textures and format negotiation

Formats

typedef enum vgl_texture_format
{
    VGL_TEXTURE_FORMAT_RGBA8 = 1,       /* always supported */
    VGL_TEXTURE_FORMAT_RGBA8_SRGB = 2,
    VGL_TEXTURE_FORMAT_BC1 = 3,
    VGL_TEXTURE_FORMAT_BC7 = 4,
    VGL_TEXTURE_FORMAT_ETC2_RGB8 = 5,
    VGL_TEXTURE_FORMAT_ASTC_4X4 = 6,
    VGL_TEXTURE_FORMAT_MAX_ENUM = 0x7FFFFFFF
} vgl_texture_format;

State once, when your renderer is created, which formats you can sample, most preferred first:

const vgl_texture_format formats[] = {VGL_TEXTURE_FORMAT_RGBA8_SRGB, VGL_TEXTURE_FORMAT_RGBA8};
vgl_context_texture_formats_set(context, formats, 2);

RGBA8 is always assumed. Until you call it, textures are RGBA8. A later call applies to textures created after it. For each imagery tile the core picks the first listed format it can produce: a pack's pre-encoded format if you list it, otherwise RGBA8 or RGBA8_SRGB decoded from JPEG or PNG. RGBA8 and RGBA8_SRGB hold the same bytes, sRGB-encoded as imagery is; RGBA8_SRGB asks you to sample them as sRGB (hardware decodes to linear), RGBA8 as plain unorm. The compressed formats are reserved for later work and need no interface change. All three built-in backends currently ask for RGBA8 only.

Layout

  • Four bytes per pixel, rows from the north, no row padding.
  • A texture is the imagery tile with its border: 258 x 258 pixels for the default 256-pixel tiles, so not a power of two. The border holds the neighbouring tiles' pixels, so clamp texture coordinates to the edge; filtering across a tile edge then needs no wrap. See Pack Format Overview.
  • The mip chain goes down to 1 x 1, each level half the one above (rounded down, at least 1), built by the core on task threads with a 2 x 2 box filter weighted by alpha, so pixels without data do not darken their neighbours. You never generate mipmaps. Upload mip_count levels from mip_data, with sizes in mip_sizes.
  • Sample with trilinear filtering (anisotropic where available) and clamp-to-edge addressing.

The draw list

vgl_frame_draw_list_get returns the tiles to draw, nearest first (which helps early depth rejection; you may reorder them). vgl_frame_draw_layers_get returns the frame's imagery layers, which items refer to by index.

vgl_draw_item

Field Meaning
tile The tile's vgl_tile_id (level, x, y)
mesh, mesh_handle The mesh's identifier and your handle (or 0)
index_list, index_list_handle The index list's identifier and your handle (or 0)
index_count, index_size What to pass to your indexed draw; index_size is 2 or 4
first_layer, layer_count The item's layers in the frame's layer list, bottom first
model_view[16] Mesh coordinates to camera space, column-major float, composed in double precision and rounded at the end
centre ECEF origin of the mesh coordinates (vgl_dvec3), for consumers that compose their own transforms
min_height, max_height The terrain's height range in the tile; skirts hang below min_height
geoid_heights[4] The geoid's height above the ellipsoid at the tile's corners: north-west, north-east, south-west, south-east
has_geoid VGL_FALSE without a geoid pack, or before it has loaded there; the heights are then 0

vgl_draw_layer

Field Meaning
texture, texture_handle The texture's identifier and your handle (or 0)
uv_scale_offset[4] (scale u, scale v, offset u, offset v): sample at uv * scale + offset. Maps the mesh's coordinates into the texture: inside its border, and into the part of an ancestor's tile when the tile is drawn with coarser imagery
opacity The pack's opacity (vgl_pack_opacity_set), always greater than 0: hidden layers are left out

Each imagery pack contributes one layer per item, from the lowest priority at the bottom to the highest at the top, with the deepest texture resident over the tile. A tile with no imagery has no layers: shade it by height and normal instead. Blend each layer over the one below by the texel's alpha times opacity. The OpenGL and Direct3D 11 backends blend the top four layers of an item.

Transforms: tile-relative vertices

Positions at planetary scale do not fit single precision: a float resolves about half a metre at the Earth's radius, which makes the surface jitter as the camera moves. vgl avoids this on both sides:

  • Tile-relative vertices. Vertex positions are single precision relative to the mesh's centre, so they stay small.
  • Camera-relative transform. The core composes view x translate(centre) per item in double precision and rounds the result to float only at the end. The translation part of model_view is then the tile centre's offset from the camera, a small number near the camera, and your shaders need no double precision.

So the vertex shader is simply:

vec4 p = u_model_view * vec4(a_position, 1.0); // camera space
gl_Position = u_projection * p;

The projection stays with you, because only you know your depth convention. Never multiply a full ECEF view matrix and a world matrix in single precision on the GPU.

If your engine composes transforms itself, build the camera-relative matrix from centre in double precision on the CPU: with the view's rotation R and translation t (camera space = R x + t), the tile's model-view has rotation R and translation R centre + t. Compute R centre + t in double and only then convert to float. Ogre consumers report model_view as each renderable's world transform with an identity view, so Ogre multiplies only the projection and needs no OGRE_DOUBLE_PRECISION build (Ogre Backend).

Normals and directions along the ECEF axes transform by the upper 3 x 3 of model_view (a rotation, without scale).

Depth conventions

You own the depth buffer, its clearing and the projection, so you choose the convention. Every built-in backend supports all three, and host geometry should use the same one so that it depth-tests correctly against the globe.

Convention vgl_depth_convention Projection Depth buffer Where
Logarithmic (default) VGL_DEPTH_CONVENTION_LOGARITHMIC Yours, conventional; its near and far planes do not matter Any; cleared to 1; compare LESS Everywhere, including OpenGL ES, WebGL and macOS
Reversed-Z VGL_DEPTH_CONVENTION_REVERSED_Z Reversed: near to 1, far (ideally infinite) to 0, depth range 0 to 1 32-bit float; cleared to 0; compare GREATER Direct3D 11, Vulkan, Metal; OpenGL 4.5, ARB_clip_control or EXT_clip_control
Conventional VGL_DEPTH_CONVENTION_CONVENTIONAL Yours as it is Any; cleared to 1; compare LESS Small scenes only: distant terrain z-fights at planetary scale

The logarithmic convention writes window depth log2(1 + w) / log2(1 + far), with w the distance along the view axis and far a far distance (VGL_LOGARITHMIC_DEPTH_FAR_DEFAULT, 1.0e8 m, when you give none). The vertex shader also moves the clip-space depth onto the same curve, so that the projection's planes clip nothing in front of the camera. With a 24-bit buffer and the default far distance it resolves about a millimetre at 1 km and a centimetre at 10 km. Write the same depth for your own geometry (an aircraft on the terrain, markers):

// GLSL (OpenGL clip space, depth -1 to 1). log_depth_coefficient = 2.0 / log2(far + 1.0)
// Vertex shader, with p the camera-space position:
v_log_depth = 1.0 + max(-p.z, 0.0);
gl_Position = u_projection * p;
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;
// HLSL (Direct3D clip space, depth 0 to 1), as backends/d3d11/terrain.hlsl does it.
// Vertex shader:
o.log_depth = 1.0 + max(-p.z, 0.0);
o.clip = mul(u_projection, p);
o.clip.z = log2(o.log_depth) * log_depth_coefficient * 0.5 * o.clip.w;
// Pixel shader, writing SV_Depth:
o.depth = log2(i.log_depth) * log_depth_coefficient * 0.5;

Using the distance along the view axis rather than clip-space w keeps the convention working with orthographic projections. Writing depth in the fragment shader disables early depth rejection on most hardware; reversed-Z avoids that where it is available.

Shader math

The interface leaves shading to you; each built-in backend's material is its own. These are the pieces every renderer of vgl data needs, as the built-in shaders implement them. The references are:

These files are not installed public API; copy what you need (they are MIT, like the rest of vgl).

Octahedral normals. Both the vertex normal and the sample normal decode the same way:

vec3 decode_octahedral(vec2 e) // e in [-1, 1]^2
{
    vec3 n = vec3(e, 1.0 - abs(e.x) - abs(e.y));
    if (n.z < 0.0)
    {
        vec2 s = vec2(e.x >= 0.0 ? 1.0 : -1.0, e.y >= 0.0 ? 1.0 : -1.0);
        n.xy = (1.0 - abs(e.yx)) * s;
    }
    return normalize(n);
}

Samples. Fetch the four samples around clamp(uv, 0, 1) * (sample_grid - 1) and interpolate the height and the decoded normals bilinearly (see surface() in the GLSL). Signed 16-bit normalized values convert as max(v / 32767.0, -1.0).

Lighting. Transform the ECEF normal to camera space with mat3(model_view) and light it with a direction you compute in double precision on the CPU (the built-in backends rotate the host's ECEF sun direction by the view's rotation, normalize it, and fall back to a headlight from the camera when it is zero).

Imagery layers. For each layer, bottom first: texel = texture(layer, uv * scale + offset); colour = mix(colour, texel.rgb, texel.a * opacity);. Start from a colour by height where imagery is missing.

Mean sea level. Interpolate geoid_heights bilinearly over the mesh's texture coordinates (u east, v south; north-west, north-east, south-west, south-east):

float geoid = mix(mix(g.x, g.y, uv.x), mix(g.z, g.w, uv.x), uv.y);
float height_above_msl = height - geoid;

Sea surface. To draw a flat sea over the ocean floor, raise vertices below mean sea level along the local vertical, normalize(centre + position), by -height_above_msl. Skirt vertices (height below the mesh's min_height) are raised only by max(geoid - min_height, 0), so they still hang below the raised surface, and are shaded as the edge above them rather than as water.

Camera space and handedness. The core's matrices are right-handed and column-major, multiplying column vectors (vgl_dmat4). Hosts with row-major matrices that multiply row vectors can pass theirs unchanged, since the two differences cancel (C API conventions, section 4.2). A left-handed camera space sees the triangles clockwise; set your culling to match.

Draw features and debug views

vgl/render.h also defines two bit sets that every built-in backend takes in its draw settings (vgl_gl_draw_info.features, vgl_d3d11_draw_info.features, vgl::ogre::globe_settings::features, and debug_views in each). The core does not use them: a custom renderer is free to implement any of these, or none.

vgl_draw_feature Value What the built-in backends draw
VGL_DRAW_FEATURE_SEA_SURFACE 0x1 Terrain below mean sea level raised to it and shaded as water (needs a geoid pack)
VGL_DRAW_FEATURE_ATMOSPHERE 0x2 Sky, limb glow seen from space, haze, sunlight reddened by its path
VGL_DRAW_FEATURE_STARS 0x4 The host's stars behind the sky
VGL_DRAW_FEATURE_CLOUDS 0x8 With the atmosphere: a cloud shell from the host's coverage image
VGL_DRAW_FEATURE_CONTOURS 0x10 Contour lines every contour interval metres above mean sea level, every fifth heavier
vgl_debug_view Value Shows
VGL_DEBUG_VIEW_WIREFRAME 0x1 The mesh's grid cells in cyan (thinned where they crowd), tile boundaries in yellow
VGL_DEBUG_VIEW_LEVEL_COLORS 0x2 Each tile tinted by its level, borders outlined
VGL_DEBUG_VIEW_IMAGERY_SOURCE 0x4 Orange where a tile uses coarser imagery (stronger the coarser), magenta where it has none
VGL_DEBUG_VIEW_HEIGHT_COLORS 0x8 Colours by height above mean sea level instead of imagery

Related constants: VGL_CONTOUR_INTERVAL_DEFAULT (500 m), VGL_CLOUD_HEIGHT_DEFAULT (6000 m), VGL_STAR_COUNT_MAX (400,000), VGL_CLOUD_IMAGE_SIZE_MIN (2), VGL_CLOUD_IMAGE_WIDTH_MAX (16,384), VGL_CLOUD_IMAGE_HEIGHT_MAX (8,192). vgl_star lives in vgl/render.h only so that the backends share one type; see Atmosphere, Stars and Clouds.

The imagery-source view needs to know how many levels coarser a layer is: the built-in backends derive it from the top layer's scale, which is a little below 1 for the tile's own imagery and halves with each ancestor level (round(-log2(scale))).

State hygiene

The raw built-in backends draw into the host's graphics context and restore every piece of state they change before returning (requirement NFR-08); each documents its list (OpenGL, Direct3D 11). If you write a backend that others will drop into their renderers, do the same: document the state you touch, save it before the frame and restore it after, and leave the framebuffer, viewport, scissor and stencil state to the host. If the renderer is your own engine's, the interface asks nothing of your state: vgl never touches the GPU.

Skeleton of a custom backend

The steps, then the code.

  1. Create your context and packs as usual (Getting Started, Context API).
  2. Negotiate formats with vgl_context_texture_formats_set when your renderer starts.
  3. Create pipeline objects: a vertex layout matching the table above, a sampler (trilinear, clamp), shaders.
  4. Each frame: set the view, update, then begin a frame on the render thread.
  5. Apply destroys, then create index lists, meshes (vertex buffer plus a samples texture) and textures (with every mip level). Set handles, report failures.
  6. Draw each item: bind its mesh, its index list, up to N layer textures with their uv_scale_offset and opacity, the geoid corners, and model_view; issue one indexed draw.
  7. End the frame.
  8. On device loss, drop your objects without using them and call vgl_context_resources_reset.
  9. On shutdown, free your objects and call vgl_context_resources_reset if another consumer may follow, all before destroying the context.
#include <vgl/render.hpp>
#include <vgl/vgl.hpp>

#include <cstdint>
#include <unordered_map>

// Your GPU objects; replace with your API's types.
struct gpu_index_list { /* index buffer */ };
struct gpu_mesh { /* vertex buffer, samples texture, grid size */ std::uint32_t sample_grid = 0; };
struct gpu_texture { /* texture with mips */ };

class my_backend
{
public:
    explicit my_backend(vgl::context& context) : context_(context)
    {
        // Most preferred first. RGBA8 is always assumed.
        const vgl::texture_format formats[] = {vgl::texture_format::rgba8_srgb,
                                               vgl::texture_format::rgba8};
        static_cast<void>(context_.texture_formats_set(formats));
        // ... create the vertex layout, sampler and shaders ...
    }

    ~my_backend()
    {
        // ... free every object in the maps ...
        // A consumer created later must receive every resource again.
        static_cast<void>(context_.resources_reset());
    }

    // Call on the render thread, after context.update(), with your framebuffer bound.
    void draw(const vgl_dmat4& projection)
    {
        auto info = vgl::init<vgl_frame_begin_info>();
        vgl::expected<vgl::frame> begun = vgl::frame::begin(context_, &info);
        if (!begun)
        {
            return;
        }
        vgl::frame& frame = *begun;

        // 1. Destroys first.
        for (const std::uint64_t id : frame.destroys_get())
        {
            index_lists_.erase(id); // ... and free the GPU objects ...
            meshes_.erase(id);
            textures_.erase(id);
        }

        // 2. Index lists, before the meshes that use them.
        for (const vgl_index_list_data& d : frame.index_lists_get())
        {
            gpu_index_list list; // upload d.index_count indices of d.index_size bytes
            index_lists_[d.resource] = list;
        }

        // 3. Meshes: vertices and their full-resolution samples.
        for (const vgl_mesh_data& d : frame.meshes_get())
        {
            if (index_lists_.find(d.index_list) == index_lists_.end())
            {
                // Its index list failed: the mesh cannot be drawn either.
                static_cast<void>(frame.resource_failed(d.resource, vgl::result::not_found));
                continue;
            }
            gpu_mesh mesh; // upload d.vertex_count * d.vertex_size bytes from d.vertices,
            mesh.sample_grid = d.sample_grid; // and d.samples as a sample_grid^2 texture
            gpu_mesh& stored = meshes_[d.resource] = mesh;
            // Optional: echo a pointer back in draw items, to skip the lookup.
            static_cast<void>(frame.resource_handle_set(
                d.resource, reinterpret_cast<std::uintptr_t>(&stored)));
        }

        // 4. Textures, every mip level, largest first.
        for (const vgl_texture_data& d : frame.textures_get())
        {
            if (d.format != VGL_TEXTURE_FORMAT_RGBA8 && d.format != VGL_TEXTURE_FORMAT_RGBA8_SRGB)
            {
                static_cast<void>(frame.resource_failed(d.resource, vgl::result::not_supported));
                continue;
            }
            gpu_texture texture; // d.width x d.height, d.mip_count levels from d.mip_data
            textures_[d.resource] = texture;
        }

        // 5. Draw, after the creates: items may use what this frame created.
        // ... bind the pipeline, set the projection and depth state for your convention ...
        const std::span<const vgl_draw_layer> layers = frame.draw_layers_get();
        for (const vgl_draw_item& item : frame.draw_list_get())
        {
            const auto mesh = meshes_.find(item.mesh);
            const auto list = index_lists_.find(item.index_list);
            if (mesh == meshes_.end() || list == index_lists_.end())
            {
                continue; // not created: skip it
            }
            for (std::uint32_t k = 0; k < item.layer_count; ++k)
            {
                const vgl_draw_layer& layer = layers[item.first_layer + k];
                const auto texture = textures_.find(layer.texture);
                if (texture == textures_.end())
                {
                    continue;
                }
                // bind texture->second with layer.uv_scale_offset and layer.opacity
            }
            // set item.model_view (column-major), item.geoid_heights, item.centre;
            // draw item.index_count indices of item.index_size bytes
        }
        // 6. The frame ends when `begun` goes out of scope (or call frame.end()).
    }

    // After your device or context was lost: forget, do not free, the old objects.
    void device_lost()
    {
        index_lists_.clear();
        meshes_.clear();
        textures_.clear();
        static_cast<void>(context_.resources_reset());
    }

private:
    vgl::context& context_;
    std::unordered_map<std::uint64_t, gpu_index_list> index_lists_;
    std::unordered_map<std::uint64_t, gpu_mesh> meshes_;
    std::unordered_map<std::uint64_t, gpu_texture> textures_;
};

The per-frame host loop around it:

auto view = vgl::init<vgl_view>();
view.view_matrix = camera_view;        // vgl_dmat4, ECEF to camera space, in double
view.projection_matrix = projection;   // vgl_dmat4
view.viewport_width = width;
view.viewport_height = height;
static_cast<void>(context.view_set(&view));
static_cast<void>(context.update());
// clear colour and depth for your depth convention, then:
backend.draw(projection);

Note the std::unordered_map in the sketch allocates; the built-in backends route all their allocations through the context's allocator (vgl_context_allocator_get) and log through vgl_context_log, which is the convention for a backend shipped as a library (C API conventions, sections 10.1 and 13).

Pitfalls

  • Reading the draw list before the creates. Items may name resources created in the same frame, with the handles set during the creates. Apply all creates first.
  • Drawing an item whose mesh failed. Skip any item whose mesh or index list you do not have.
  • Keeping list pointers after vgl_frame_end. They point into library memory and are invalid afterwards.
  • Overlapping update and frame. vgl_context_update fails with VGL_RESULT_INVALID_OPERATION while a frame is open.
  • Wrapping or not clamping texture coordinates. Textures carry a border; clamp to edge.
  • Composing ECEF transforms in float. Use model_view, or compose from centre in double.
  • Two consumers on one context. Resources are offered once; a second consumer would never see them. Reset when switching.
  • Primitive restart on. Switch it off for 16-bit index lists.
  • Hard-coding 256-pixel textures. Use width, height and mip_count from the texture data.

See also

Clone this wiki locally