Skip to content

OpenGL Backend

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

OpenGL backend

The OpenGL backend (vgl/gl.h, C++ wrapper vgl/gl.hpp, CMake target vgl::gl, sources in backends/gl) draws a context's globe into the host's OpenGL 3.3, OpenGL ES 3.0 or WebGL 2 framebuffer. This page covers what it needs from the host (a current context and a function loader), its create and draw parameters, the depth setup per convention, the state it changes and restores, the sea surface and debug views, how to fit it into an existing OpenGL renderer (pass order, MSAA, sRGB), device loss, and the OpenGL and pure-C samples. It is a consumer of the render interface in a library of its own, built on the public C API only, like a third-party backend would be.

Contents

Requirements

Item Requirement
API OpenGL 3.3 or later (the samples use a core profile) or OpenGL ES 3.0 or later. WebGL 2 through Emscripten counts as OpenGL ES 3.0
Context Created by the host and current on the calling thread. The backend never touches EGL, WGL, GLX or any windowing library
Loader A function that returns OpenGL entry points, including OpenGL 1.x ones
Reversed-Z Clip control: OpenGL 4.5, GL_ARB_clip_control, or GL_EXT_clip_control on OpenGL ES. Not available on WebGL
Optional Anisotropic filtering (OpenGL 4.6, GL_EXT_texture_filter_anisotropic or GL_ARB_texture_filter_anisotropic), used up to 8x when present

The shaders are written once, in the dialect shared by GLSL 3.30 and GLSL ES 3.00, with a version and precision preamble chosen at run time (#version 330 core, or #version 300 es with highp precision). The backend links no OpenGL library, so the same binary serves desktop OpenGL and OpenGL ES.

Building and linking

The backend is on by default (VGL_BACKEND_GL, see Building vgl). Link it next to the core:

find_package(vgl CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE vgl::vgl vgl::gl)

When vgl is built shared (BUILD_SHARED_LIBS=ON), the backend is a shared library too (vgl-gl). On platforms with pkg-config, the module is vgl-gl. See Installing vgl for vcpkg (feature gl) and Conan (option with_gl).

Quick start

The four-step integration, in C, as the pure-C sample does it with GLFW:

#include <vgl/defaults.h>
#include <vgl/gl.h>
#include <vgl/vgl.h>
#include <GLFW/glfw3.h>

static vgl_gl_proc load(void* user_data, const char* name)
{
    (void)user_data;
    return (vgl_gl_proc)glfwGetProcAddress(name);
}

/* After creating the window, making its context current, and creating the vgl context
 * and its packs (Getting-Started): */
vgl_gl_backend_create_info backend_info = {0};
backend_info.struct_size = sizeof(backend_info);
backend_info.get_proc_address = load;
vgl_gl_backend* backend = NULL;
if (vgl_gl_backend_create(context, &backend_info, &backend) != VGL_RESULT_SUCCESS)
{
    /* the context's log says why */
}

vgl_gl_draw_info draw_info = {0};
draw_info.struct_size = sizeof(draw_info);
draw_info.sun_direction.x = 1.0; /* towards the sun, along the ECEF axes */

while (!glfwWindowShouldClose(window))
{
    int width, height;
    vgl_view view = {0};
    glfwPollEvents();
    glfwGetFramebufferSize(window, &width, &height);

    view.struct_size = sizeof(view);
    view.view_matrix = my_view_matrix();               /* vgl_dmat4, ECEF to camera, double */
    view.projection_matrix = my_projection(width, height);
    view.viewport_width = (uint32_t)width;
    view.viewport_height = (uint32_t)height;
    vgl_context_view_set(context, &view);
    vgl_context_update(context, NULL);

    glViewport(0, 0, width, height);
    glClearColor(0.02f, 0.02f, 0.05f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); /* depth cleared to 1: logarithmic */
    vgl_gl_backend_draw(backend, &draw_info);
    glfwSwapBuffers(window);
}

vgl_gl_backend_destroy(backend); /* while the OpenGL context exists, before the vgl context */

In C++, with the wrapper:

#include <vgl/gl.hpp>
#include <vgl/vgl.hpp>

vgl_gl_proc get_proc_address(void* /*user_data*/, const char* name)
{
    return reinterpret_cast<vgl_gl_proc>(glfwGetProcAddress(name));
}

auto info = vgl::init<vgl_gl_backend_create_info>();
info.depth_convention = VGL_DEPTH_CONVENTION_LOGARITHMIC;
info.get_proc_address = get_proc_address;
vgl::expected<vgl::gl_backend> backend = vgl::gl_backend::create(context, info);
if (!backend)
{
    std::fprintf(stderr, "backend: %s\n", vgl::result_name_get(backend.error()));
}

// Each frame, after context.update(), in your render pass:
auto draw = vgl::init<vgl_gl_draw_info>();
draw.view_matrix = &view_matrix;             // optional, defaults to the context's view
draw.projection_matrix = &projection_matrix; // optional, defaults to the context's view
draw.features = VGL_DRAW_FEATURE_ATMOSPHERE;
static_cast<void>(backend->draw(&draw));

vgl::gl_backend destroys the backend in its destructor; make sure that runs while the OpenGL context is still current and before the vgl::context is destroyed.

Loading OpenGL functions

The host passes a loader in vgl_gl_backend_create_info.get_proc_address:

typedef void (*vgl_gl_proc)(void);
typedef vgl_gl_proc (*vgl_gl_get_proc_address_fn)(void* user_data, const char* name);

It is called with names such as "glDrawElements", inside vgl_gl_backend_create and vgl_gl_backend_device_reset only, on the calling thread, with get_proc_address_user_data as user_data. It must also return OpenGL 1.x functions (glGetString, glEnable, glDrawElements...). Loaders that do:

Loader Notes
glfwGetProcAddress Used by the samples, natively and on the web through Emscripten's GLFW port
SDL_GL_GetProcAddress Returns void*; cast it to vgl_gl_proc
eglGetProcAddress (EGL 1.5) EGL 1.5 returns core functions too
wglGetProcAddress Not enough on its own: it returns no OpenGL 1.1 functions. Fall back to GetProcAddress on opengl32.dll, as below
/* Raw WGL: user_data is the HMODULE of opengl32.dll (LoadLibraryA("opengl32.dll")). */
static vgl_gl_proc load_wgl(void* user_data, const char* name)
{
    PROC p = wglGetProcAddress(name);
    if (p == NULL || p == (PROC)1 || p == (PROC)2 || p == (PROC)3 || p == (PROC)-1)
    {
        p = GetProcAddress((HMODULE)user_data, name);
    }
    return (vgl_gl_proc)p;
}

If you already use glad, GLEW or another loader, you can still pass the platform loader: the backend keeps its own table of the functions it uses and never relies on your loader's globals. The version comes from glGetString(GL_VERSION): a string starting with OpenGL ES selects the ES path, and one containing WebGL the WebGL path.

Creating the backend

vgl_gl_backend_create_info is a versioned structure: zero-initialize it, set struct_size, then fill in.

Field Meaning Default
struct_size sizeof(vgl_gl_backend_create_info) Required
depth_convention VGL_DEPTH_CONVENTION_LOGARITHMIC, _REVERSED_Z or _CONVENTIONAL (Render Interface) Logarithmic (0)
get_proc_address The loader Required
get_proc_address_user_data Passed to the loader Null

vgl_gl_backend_create(context, &info, &backend) must run on the render thread with the OpenGL context current. It loads the functions, checks the version, compiles and links its programs (terrain, sky and stars), creates a sampler, and tells the context which texture formats it samples (RGBA8). Its objects belong to the current OpenGL context or its share group. It allocates through the context's allocator and logs through the context's log.

Result Cause
VGL_RESULT_SUCCESS Created; the log says "drawing with OpenGL [ES] major.minor" at info level
VGL_RESULT_INVALID_ARGUMENT A null pointer, a small struct_size, no loader, or an unknown depth convention
VGL_RESULT_NOT_SUPPORTED Fields of a newer vgl set; or the version is below 3.3 / ES 3.0, a function is missing, no context is current, or reversed-Z without clip control. The log names which
VGL_RESULT_BACKEND_ERROR A shader failed to compile or link; the log has the driver's message
VGL_RESULT_OUT_OF_MEMORY Allocation failed

A context has at most one render-interface consumer at a time. vgl_gl_backend_destroy deletes the backend's OpenGL objects and calls vgl_context_resources_reset, so a backend created later receives every resource again. Destroy it with its OpenGL context (or one sharing its objects) current, and before the vgl context.

Drawing

Call vgl_gl_backend_draw(backend, &info) once per frame, after vgl_context_update, inside your render pass, with the target framebuffer bound and the viewport set. It begins a frame, applies the resource lists (deleting and creating buffers, vertex arrays and textures), draws one indexed draw call per tile with up to four imagery layers, then the stars and the sky if enabled, restores your state, and ends the frame. It does not clear or present. Without a view it draws nothing.

vgl_gl_draw_info fields (all optional; info itself may be null for all defaults):

Field Meaning 0 or null selects
struct_size sizeof(vgl_gl_draw_info) Required when info is given
mirrored VGL_TRUE when the projection mirrors the image (a flipped y axis for rendering into a texture): the backend culls the other side Not mirrored
view_matrix const vgl_dmat4*, the camera to draw from when it differs from the last update's view The context's view
projection_matrix const vgl_dmat4*, camera to clip space, OpenGL depth -1 to 1 for logarithmic and conventional, 0 to 1 for reversed-Z The context's view's projection
sun_direction Towards the sun along the ECEF axes, need not be normalized Zero: light from the camera (a headlight); with the atmosphere, a sun straight above the camera
logarithmic_far The logarithmic convention's far distance, metres VGL_LOGARITHMIC_DEPTH_FAR_DEFAULT (1.0e8)
upload_budget Bytes of resources to upload in this draw VGL_UPLOAD_BUDGET_DEFAULT (16 MiB)
features vgl_draw_feature bits: sea surface, atmosphere, stars, clouds, contours Terrain and imagery only
debug_views vgl_debug_view bits Normal drawing
earth_rotation_angle Radians, from vgl_earth_rotation_angle_get, for the stars 0
contour_interval Metres between contour lines VGL_CONTOUR_INTERVAL_DEFAULT (500)
cloud_height The cloud shell's height above the ellipsoid, metres VGL_CLOUD_HEIGHT_DEFAULT (6000)
Result Cause
VGL_RESULT_SUCCESS Drawn
VGL_RESULT_INVALID_ARGUMENT A matrix or sun element not finite, unknown feature or debug bits, or a distance, interval or height not finite or negative
VGL_RESULT_NOT_SUPPORTED Fields of a newer vgl set
VGL_RESULT_INVALID_OPERATION A frame of the context is already open, or the last vgl_gl_backend_device_reset failed
VGL_RESULT_OUT_OF_MEMORY The frame could not begin

Shading

Each tile blends up to four imagery layers (the top four if a tile has more), bottom first, by each texel's alpha times the layer's opacity, over colours by height where imagery is missing, lit by the normal from the mesh's full-resolution samples. One sampler serves every layer: trilinear, anisotropic up to 8x where available, clamped to the edge. Textures are created as GL_RGBA8 and sampled as they are (sRGB-encoded bytes, unconverted); see sRGB.

Texture units 0 to 3 hold the layers, unit 4 the mesh's samples (a GL_RG32UI texture), and unit 5 the clouds. All are restored afterwards.

Depth setup per convention

The host owns the depth buffer, its clearing and the projection. Set them up to match the convention you created the backend with, and use the same convention for your own geometry (Render Interface has the shader snippet for logarithmic depth).

Convention Depth buffer Clear Projection Clip control
Logarithmic Any (24-bit is enough) glClearDepth(1) Conventional OpenGL, depth -1 to 1; near and far do not matter (the samples use 1 m and 1e9 m) None
Reversed-Z GL_DEPTH_COMPONENT32F glClearDepth(0) (or glClearDepthf(0) on ES) Reversed, near to 1 and far to 0, ideally infinite The backend sets GL_ZERO_TO_ONE while it draws, keeping your origin, and restores yours
Conventional Any glClearDepth(1) Yours as it is; keep near and far tight around the view None

The backend enables the depth test and writes, and compares with GL_LESS (logarithmic and conventional) or GL_GREATER (reversed-Z). The sky and stars draw at the far depth with GL_LEQUAL or GL_GEQUAL and no depth writes, so they show only where nothing was drawn.

A reversed projection with an infinite far plane, as the samples build it (column-major):

// Near maps to 1 and infinity to 0, in a depth range from 0 to 1.
vgl_dmat4 perspective_reversed(double fovy, double aspect, double near_plane)
{
    const double f = 1.0 / std::tan(fovy / 2.0);
    vgl_dmat4 m{};
    m.m[0] = f / aspect;
    m.m[5] = f;
    m.m[11] = -1.0;
    m.m[14] = near_plane;
    return m;
}

For reversed-Z, also set clip control for your own geometry; the backend keeps whatever origin you set and restores your depth mode:

glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE); /* glClipControlEXT on OpenGL ES */

Default framebuffers usually have a 24-bit integer depth buffer, which defeats reversed-Z. The OpenGL sample therefore draws into its own framebuffer object with a GL_DEPTH_COMPONENT32F renderbuffer for reversed-Z (GL_DEPTH_COMPONENT24 otherwise) and blits the colour to the window.

State it changes and restores

The backend saves this state when it draws and restores it before returning (requirement NFR-08):

  • the current program, the vertex array binding, and the GL_ARRAY_BUFFER, GL_COPY_WRITE_BUFFER and GL_PIXEL_UNPACK_BUFFER bindings
  • the active texture unit, and the 2D texture and sampler bindings of units 0 to 5
  • the unpack alignment, row length, skip rows and skip pixels
  • GL_DEPTH_TEST, the depth function and the depth write mask
  • GL_CULL_FACE, the cull face mode and the front face
  • GL_BLEND, the blend functions and equations
  • primitive restart (GL_PRIMITIVE_RESTART on desktop, GL_PRIMITIVE_RESTART_FIXED_INDEX where it exists), which it switches off while drawing
  • for the reversed-Z convention, the clip control origin and depth mode

It draws with your framebuffer, viewport, scissor, depth range, colour mask and stencil state unchanged: a scissor, stencil test or colour mask you leave enabled applies to the globe too. It does not save or change GL_FRAMEBUFFER_SRGB, GL_MULTISAMPLE, polygon offset or polygon mode, so leave those as the globe needs them (off, except multisampling). The element array buffer binding belongs to the vertex array; the backend uploads index lists through its own vertex array and leaves yours untouched.

The OpenGL sample's check sets sentinel state (blending on, depth test off, GL_ALWAYS, culling off, front face clockwise, a vertex array of its own) before each draw and verifies it afterwards.

Sea surface, contours and debug views

Pass vgl_draw_feature bits in features and vgl_debug_view bits in debug_views (Render Interface has the tables).

draw_info.features = VGL_DRAW_FEATURE_SEA_SURFACE | VGL_DRAW_FEATURE_CONTOURS;
draw_info.contour_interval = 100.0; /* metres */
draw_info.debug_views = VGL_DEBUG_VIEW_LEVEL_COLORS | VGL_DEBUG_VIEW_IMAGERY_SOURCE;
  • Sea surface (VGL_DRAW_FEATURE_SEA_SURFACE): terrain below mean sea level is raised to it in the vertex shader and shaded as water, with the imagery's ocean colour where there is imagery and a glint of the sun. Mean sea level comes from the geoid's heights at each tile's corners, so add a geoid pack (the sample set's packs/egm2008); without one, the ellipsoid is sea level, up to about 100 m off. Skirts are raised only as far as the tile's lowest point, so they still cover cracks.
  • Contours (VGL_DRAW_FEATURE_CONTOURS): lines every contour_interval metres above mean sea level, every fifth heavier, anti-aliased, fading out where they crowd. The samples pick an interval from the altitude.
  • Debug views: the wireframe (VGL_DEBUG_VIEW_WIREFRAME), level colours, imagery source and height colours, combinable.

The atmosphere, stars and clouds have a page of their own: Atmosphere, Stars and Clouds.

Integrating with an existing OpenGL renderer

Render pass order

A typical frame:

  1. Bind your framebuffer, set the viewport, clear colour and depth (to 1, or 0 for reversed-Z).
  2. Draw any background that sits behind the globe and the sky (a space skybox, for example) without writing depth. The sky is blended over what the framebuffer holds where the atmosphere is thin, and the stars are added to it.
  3. Draw your opaque geometry with the same depth convention. The globe depth-tests against it, and the sky does not cover it, since the sky draws only where the depth is still at the far plane.
  4. vgl_gl_backend_draw.
  5. Draw transparent geometry and effects that should appear in front of the sky, then your UI.

Drawing opaque geometry after the globe also works for depth testing; drawing it before lets the sky skip those pixels. The globe receives no haze over your geometry: the atmosphere's haze is applied to the globe's own terrain only.

MSAA

The backend draws into whatever framebuffer is bound, including a multisampled one; resolve it yourself (for example with glBlitFramebuffer). With the logarithmic convention, the fragment shader writes gl_FragDepth, which gives every sample of a pixel the same depth and disables early depth rejection on most hardware: edges against the background are still antialiased, but intersections with your geometry inside a pixel are not. The reversed-Z convention writes no fragment depth and avoids both, where clip control exists.

sRGB

The backend asks the core for VGL_TEXTURE_FORMAT_RGBA8 and samples imagery as stored, sRGB-encoded, so the colour it writes is already display-encoded. Draw into an GL_RGBA8 (unorm) target, or keep GL_FRAMEBUFFER_SRGB disabled while it draws; with sRGB conversion on, the globe looks washed out. If your renderer works in linear HDR, draw the globe into a separate unorm target and composite it, or convert its output yourself.

Mirrored projections

When you render into a texture with a flipped y axis (to read it the right way up), triangles face the other way: set mirrored = VGL_TRUE.

Several views

One context selects tiles for one view. To draw the same globe from a second camera (a minimap, a reflection), pass that camera in view_matrix and projection_matrix: it draws the tiles selected for the context's view, as the samples' "freeze selection" mode shows. For a fully independent view, use a second context.

Threads and contexts

Create, draw, set stars and clouds, reset and destroy on the thread where the OpenGL context is current. The draw must not overlap vgl_context_update; update and draw on one thread, or serialize them.

Lost contexts

When the OpenGL context is lost (an Android app returning from the background, a destroyed and recreated context), make the new context current and call:

if (vgl_gl_backend_device_reset(backend) != VGL_RESULT_SUCCESS)
{
    /* draws fail with VGL_RESULT_INVALID_OPERATION until a reset succeeds */
}

It forgets the old objects without deleting them, loads the functions and builds its programs again on the current context, and resets the context's resources, which upload again over the next frames. Stars and clouds set before are kept on the CPU and upload again too.

The OpenGL and C samples

Sample Source Executable What it shows
OpenGL samples/gl/main.cpp vgl-gl GLFW window, OpenGL 3.3 or (--es) OpenGL ES 3.0, navigation like Google Earth, a Dear ImGui panel with statistics and debug views, the atmosphere, stars and clouds; also builds for the browser on WebGL 2
Pure C samples/c/main.c vgl-c The core of the integration in C99: GEBCO, the geoid and Blue Marble, a camera circling the globe
vgl-gl [--data <directory> | --pack <uri>...] [--cache <directory> [--cache-budget <MiB>]]
       [--es] [--egl] [--depth logarithmic|reversed|conventional] [--check]
       [--save <directory>] [--stars <catalog>]
vgl-c  [--data <directory> | --pack <uri>...] [--check]
  • --es creates an OpenGL ES 3.0 context, --egl creates it through EGL (for example to reach ANGLE).
  • --depth picks the convention; the default is logarithmic. Reversed-Z exits with a message on a device without clip control.
  • --check runs hidden: it flies to a few views with each convention the device supports, compares the images across conventions, checks for OpenGL errors and that the backend restored the host's state, and exits with 1 on failure. --save writes the checked images as PPM files.
  • --stars reads the Yale Bright Star Catalogue (see Atmosphere, Stars and Clouds).
  • Keys: F1 panel, F2 wireframe, F3 level colours, F4 imagery source, F5 freeze selection, F6 height colours, F7 contours, F8 sea surface, F9 atmosphere, L sunlight or headlight, 1 to 7 places.

The checks are CTest tests (gl, gl_es, gl_c) when configured with -DVGL_TEST_GL=ON, since they need a display. The browser build is described on Web Builds; data and options on Samples.

Troubleshooting

Symptom Likely cause
Create fails with VGL_RESULT_NOT_SUPPORTED, "the loader returned no glGetString" (or another function) The loader returns no OpenGL 1.x functions (raw wglGetProcAddress), or the context is older than it claims
Create fails, "glGetString(GL_VERSION) failed; is a context current?" No current context on the calling thread
Create fails for reversed-Z No clip control; use the logarithmic convention
Nothing drawn No view set (vgl_context_view_set), no packs loaded yet, or the depth buffer cleared to the wrong value for the convention
Terrain flickers in the distance Conventional convention at planetary scale, or reversed-Z with a 24-bit depth buffer
Colours washed out GL_FRAMEBUFFER_SRGB enabled on an sRGB target
Your geometry disappears behind the globe Your geometry uses a different depth convention than the backend

More on Troubleshooting.

See also

Render Interface, Atmosphere, Stars and Clouds, Direct3D 11 Backend, Ogre Backend, Web Builds, Mobile Platforms, Samples, and the header backends/gl/include/vgl/gl.h.

Clone this wiki locally