Repository navigation
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).
- When to use it
- Rules of the interface
- Frame flow
- Beginning a frame
- Resources
- Meshes: vertices, samples and index lists
- Textures and format negotiation
- The draw list
- Transforms: tile-relative vertices
- Depth conventions
- Shader math
- Draw features and debug views
- State hygiene
- Skeleton of a custom backend
- Pitfalls
| 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.
-
Pulled, not pushed. The core never calls into your renderer. Each frame you call
vgl_context_frame_begin, walk the lists it returns, and callvgl_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_beginandvgl_frame_endevery 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_updatereturnsVGL_RESULT_INVALID_OPERATIONwhile a frame is open, andvgl_context_frame_beginreturns 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.
Per frame, on the render thread:
- Set the view (
vgl_context_view_set) and callvgl_context_update. These select the tiles and drive loading; they can run on another thread, as long as they do not overlap the frame. -
vgl_context_frame_beginreturns avgl_frame*. - Apply the resource lists, in this order:
-
vgl_frame_destroys_get: destroy these resources first, so memory is freed before anything new is created. -
vgl_frame_index_lists_get: create index lists. -
vgl_frame_meshes_get: create meshes. A mesh's index list was created in this frame or an earlier one. -
vgl_frame_textures_get: create textures. While creating, you may callvgl_frame_resource_handle_set(your handle comes back in draw items) orvgl_frame_resource_failed(the resource could not be created).
-
- 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. -
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)
Without a view, a frame draws nothing but still offers destroys of what is no longer needed.
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.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);
/* ... */
}| 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.
| 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.
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. A frame offers creates up to
upload_budgetbytes, 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.
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.
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).
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).
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 |
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).
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.
- 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_countlevels frommip_data, with sizes inmip_sizes. - Sample with trilinear filtering (anisotropic where available) and clamp-to-edge addressing.
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.
| 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 |
| 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.
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 tofloatonly at the end. The translation part ofmodel_viewis 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).
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.
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:
- GLSL 3.30 / GLSL ES 3.00: backends/gl/shaders.hpp
- HLSL shader model 5.0: backends/d3d11/terrain.hlsl
- Ogre unified shaders: backends/ogre/shaders.hpp
- Shared, all backends: backends/common/atmosphere.glsl (atmosphere and clouds, in the subset of GLSL that HLSL shares), atmosphere_view.hpp (its CPU-side values in double precision), stars.hpp, clouds.hpp, draw_flags.hpp
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.
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))).
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.
The steps, then the code.
- Create your context and packs as usual (Getting Started, Context API).
-
Negotiate formats with
vgl_context_texture_formats_setwhen your renderer starts. - Create pipeline objects: a vertex layout matching the table above, a sampler (trilinear, clamp), shaders.
- Each frame: set the view, update, then begin a frame on the render thread.
- Apply destroys, then create index lists, meshes (vertex buffer plus a samples texture) and textures (with every mip level). Set handles, report failures.
-
Draw each item: bind its mesh, its index list, up to N layer textures with their
uv_scale_offsetandopacity, the geoid corners, andmodel_view; issue one indexed draw. - End the frame.
-
On device loss, drop your objects without using them and call
vgl_context_resources_reset. -
On shutdown, free your objects and call
vgl_context_resources_resetif 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).
- 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_updatefails withVGL_RESULT_INVALID_OPERATIONwhile 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 fromcentrein 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,heightandmip_countfrom the texture data.
Getting vgl
Using the library
Rendering
Data
Reference
Specifications