OpenGLContext Structural Overview

The OpenGLContext Package (top-level)

Within the top-level OpenGLContext package are the objects implementing the Context interface. Each supported GUI library defines a derived Context class which overrides various methods to support the Context API (these derived classes are named GLUTContext, PygameContext, wxContext, etceteras).  Each supported GUI will also provide a sub-class which uses the mix in InteractiveContext, which, through the events package (see below), allows for keyboard and mouse event processing.  Each GUI library will then provide a module Xtestingcontext.py (e.g. gluttestingcontext.py) which provides a context class factory function, and a "mainloop" function. The testingcontext.py module then provides an interface for finding the appropriate testing context for the specified GUI library.

Context classes are registered by calling OpenGLContext.plugins.Context with a name and the dotted path to the class, which OpenGLContext/__init__.py does for the in-tree backends. A third party registers its own the same way, at import time:

from OpenGLContext.plugins import Context, InteractiveContext

Context( 'mytoolkit', 'mypackage.context.MyContext' )
InteractiveContext( 'mytoolkit', 'mypackage.context.MyInteractiveContext' )

The registry is a list in the running process, so what puts a third-party backend on it is the application importing that package — there is no entry-point scan and no install step beyond having the package importable. OPENGLCONTEXT_BACKEND=mytoolkit then selects it by the name given here. Loaders, viewer adapters and scenegraph nodes are registered the same way through plugins.Loader, plugins.Adapter (see Adding a format) and plugins.Node.

Which backends are supported

GLFW, GLUT, Pygame, Tkinter, wxPython and Qt/PySide are all first-class targets. Breaking one of them is a regression, not a cleanup, and none of them is a candidate for removal. GLFW is what the test suite and the visual-regression captures run against, and it is the recommended backend for core-profile and PBR rendering; Tkinter needs nothing installed, since it ships with Python, and is the one to reach for in a tool whose dependencies are meant to stay short; wxPython and Tkinter both embed a GL view inside a larger application's window rather than owning the whole window (see tests/wx_with_controls.py, and TkContext(parent=...)) — the wx backend asks for its GL context through wx.glcanvas.GLContext, so it wants wxPython 4 (Phoenix) or newer; Qt/PySide is supported through the separate OpenGLContext_qt project, which OpenGLContext imports opportunistically. All of them can request a core profile, and all of them can create a compatibility-profile context for the fixed-function tutorials and for the display-list-backed font providers.

The Tk backend draws into OpenGL.Tk.GLFrame — PyOpenGL's own widget, which makes a GL context on the window Tk hands out, through GLX on X11 and WGL on Windows. Tk has no Wayland backend, so a Wayland-only session runs it through XWayland and a headless machine runs it under xvfb-run, as GLUT and Pygame also need. A Tk window has no native handle until it has been mapped, so OPENGLCONTEXT_HIDDEN is honoured by withdrawing the window once the context exists: it appears and goes, rather than never appearing, and rendering and reading back are unaffected because both happen in the back buffer.

A seventh backend owns no window at all. eglcontext.EGLContext takes a GPU directly through EGL and renders to a pbuffer, which is what a build machine, a rendering service or a batch job wants: no display server has to be running. It is the one backend that is not available everywhere, because EGL is not — it is a Linux and Android facility, and elsewhere a hidden window (OPENGLCONTEXT_HIDDEN=1) is the way to render without one being seen.

Code that targets one platform is supported on that platform whether or not the machine you are reading this on can run it -- for instance scenegraph/text/wglfont.py is Windows font support, and is expected to work on Windows.

What every backend offers

The toolkit an application already uses should choose a window system, not a subset of the engine, so the window-level capabilities are the same on all of them. Each is a method on Context that answers whether it happened, so a caller can tell "this platform will not" from "nobody implemented this" and offer the user something else.

MethodDoesWhere it answers False
setPointerCapture(on) Hides the pointer and reports unbounded motion, which is what mouse-look turns from A window system that will not give a client the pointer — Qt's Wayland plugin, for one; turning is then limited to the window
setFullscreen(on) Moves a live window to the screen and back, keeping its GL context A machine with no monitor attached, or a session with no window manager to honour the request
applyVSync() Waits for the display's refresh, or does not (ContextDefinition.vsync) Qt and Pygame, where the interval is part of a surface format settled when the context is made: the change is logged and takes effect in the next window. GLUT, Tk and wxPython ask the window system's own swap-control extension, and answer False where there is none
pumpWindowEvents() Delivers whatever the window system has queued, for a program driving its own loop rather than calling MainLoop The offscreen backend, which has no window system to ask
releaseWindow() Lets the window, and the GL objects in it, go — one name, so a caller that built a context need not know which backend made it Answers nothing; there is nothing to report about having done it

context.setVSync(False) is the call an application makes to uncap its frame rate: it writes the field and applies it. That matters more than it sounds — a forced redraw blocks on a buffer swap nobody is presenting, so a headless capture that does not uncap draws one frame and then waits for ever — and reaching for a particular toolkit's own swap-interval call instead does nothing on any other backend.

A context knows how big it is as soon as it exists: getViewPort() answers a width, height pair before a single event has been pumped, so a program that draws its first frame before entering a loop gets a projection matrix, an overlay scale and a picking ray of the right size. A window system that reports the size through a resize callback is asked directly instead, as the window is made.

Beside those, every backend reports pointer motion to the movement sampler as it happens (recordPointerMotion), supplies key-repeat where the platform delivers none, and releases every held key when its window loses focus — no platform sends a key-up for a key that was down when focus went elsewhere, and without the release the camera keeps moving with nobody touching the keyboard. The held-key half is events.eventhandlermixin.HeldKeyMixin, shared. See Movement Modes & Navigation.

Each backend's loop has the same shape: the window system's events are pumped, then the animation hook, then one render for the iteration whatever arrived — so a burst of input coalesces into a single frame rather than forcing a render per event — with the phases timed by looptrace and the telemetry and stall journals closed as the loop ends. GLUT's is built on glutMainLoopEvent; a GLUT without it keeps the older arrangement, where the toolkit owns the loop. wxPython's loop is wx's own, and the phases are not timed there.

An application whose loop is already running keeps it: the view goes in a widget beside the rest of the interface, and the frames come out of the host's loop rather than the engine's. That is Embedding a view in an application, which covers what each of Tk, Qt and wx needs and has a sample program for each.

Filling the screen

A game normally wants the whole display and a tool normally does not, so which it is belongs to the program rather than to whoever launches it: ContextDefinition.fullscreen, defaulted from OPENGLCONTEXT_FULLSCREEN and offered on the settings screen under Interface.

from OpenGLContext.contextdefinition import ContextDefinition
MyGame.ContextMainLoop(definition=ContextDefinition(fullscreen=True))

The window opens at the display's current resolution, so nothing switches video modes: a mode change is slow and rearranges the icons on every other desktop that display is showing. A program that wants a different rendering resolution says so with the definition's size.

OPENGLCONTEXT_HIDDEN outranks it. A window that is not meant to appear cannot fill the screen -- the platforms take "fill the screen" as an instruction to map it -- so a capture subprocess that honoured both would put itself over the display of whoever started the run.

context.setFullscreen(True/False) moves a window that already exists, keeping the GL context and everything loaded into it, and answers whether the backend could. Every backend can. Applying the settings screen's toggle calls it, so a player can leave a full-screen game without restarting.

Rendering runs through the passes package described in Rendering Passes below: a single "flat" pass that observes the scenegraph's structure and draws it in a fixed sequence (background, opaque, transparent, selection, overlay). See Flat Rendering for the overview.

Earlier versions instead gave each Context a list of RenderMode objects (Timer, Opaque, Transparent, Select) driven by a visitor-pattern traversal. That system was removed once the flat pass replaced it; Context.renderPasses now names the flat-pass dispatcher.

OpenGL Core Profile Support

A context is core profile unless something asks otherwise. Core profile uses GLSL shaders rather than the fixed-function pipeline, which is what makes it work on OpenGL 3.3+ contexts and on platforms such as macOS that offer nothing else — and it is what the engine's own geometry draws through, since PBRMesh, and so the glTF loader and every generator built on it, is shader-only. Every backend (glfw, glut, pygame, wx, qt) creates one.

The compatibility profile is fully supported, and is what a program asks for when it means to draw with the older pipeline.

Saying which profile your program needs

OPENGLCONTEXT_PROFILE=compatibility settles the profile for a whole run, which is what a CI job or a one-off comparison wants. A program that needs a particular profile says so on its Context class instead, so the requirement travels with the code and nobody has to know to set a variable before running it:

class MyContext( BaseContext ):
    profile = 'compatibility'   # this program draws with the fixed-function pipeline

Use this for anything calling glBegin, glVertexPointer, glMaterial, glLight, the matrix stack, display lists, or GLSL's gl_ModelViewProjectionMatrix and friends: none of them exist in a core context.

profile settles the OpenGL version to go with it, and is applied over any contextDefinition the class declares, so a subclass can name its profile and still inherit the size, buffers and rendering features its base asked for. Where more than the profile is at stake, declare the whole definition:

from OpenGLContext import contextdefinition

class MyContext( BaseContext ):
    contextDefinition = contextdefinition.ContextDefinition(
        profile = 'compatibility',
        size = (800, 600),
        multisampleSamples = 4,
    )

Either declaration is read before the window is created, on every backend -- the profile, the version and the buffer formats are all window-creation parameters, so there is no configuring them afterwards. A definition passed to the constructor outranks both. Each context gets its own copy of what the class declared, since a context writes its own size back to its definition as the window is resized.

wxPython GTK3 and EGL

Important: wxPython on GTK3 uses EGL (not GLX) for OpenGL context creation. When using core profile rendering with wxPython, you must configure PyOpenGL to use EGL for proper context tracking:

PYOPENGL_PLATFORM=egl python your_script.py

The wxcontext module attempts to set this automatically, but if OpenGL is imported before wxcontext, you may need to set it manually. Symptoms of missing EGL configuration include errors like "Attempt to retrieve context when no valid context" during shader rendering. Legacy (fixed-function) rendering typically works without this setting.

Qt/PySide and the platform plugin

The Qt backend lives in the separate OpenGLContext-qt distribution and registers itself under the name qt, so installing it is all that is needed for OPENGLCONTEXT_BACKEND=qt to select it. It is built on QWindow with a QOpenGLContext of its own rather than on QOpenGLWidget, so framebuffer 0 is the screen and every render path -- the bloom composite, the selection buffer's blit, the back-buffer read behind a screenshot -- behaves as it does under GLUT and GLFW. Both profiles are supported, and every ContextDefinition field that describes the window is mapped to the Qt surface format.

OPENGLCONTEXT_BACKEND=qt python your_script.py

Which Qt platform plugin you get matters. Qt asks the platform integration for the context, and some of them hand back one that is current on no drawable surface at all: every GL call succeeds, nothing raises, and every frame is black. The backend detects that at start-up and says so, naming the plugin. Selecting another one usually fixes it:

QT_QPA_PLATFORM=xcb OPENGLCONTEXT_BACKEND=qt python your_script.py

Two limits are worth knowing. accumulationBuffer does not exist in Qt 6 and is reported rather than silently dropped, and OPENGLCONTEXT_HIDDEN is not supported -- Qt's offscreen surfaces have no default framebuffer on the common EGL platforms, so headless capture should use the glfw backend.

When a context goes away

A GL object is a name, and it means something only in the context that issued it. The engine's caches of them — the render pass, the VRML97 shader programs, the text renderers, the teapot's vertex arrays — are therefore keyed by the GL context as well as by whatever else identifies the entry.

Keying alone is not enough. The key is the context's handle, the handle is an address, and a driver hands the same address out again for the next context: a cache that keys on it and is never told the old context died answers the new one with the dead one's names. PyOpenGL's own dispatch tables are keyed the same way and have the same exposure — a recycled handle arrives holding the dead context's resolved function pointers.

So a backend says both things, and Context states the contract once so that a backend inherits it rather than having to know it:

self.bindContextResources( handle )      # in setCurrent
self.releaseContextResources( handle )   # as the window is destroyed

releaseContextResources tells the engine's caches, so they can delete what they hold rather than merely forget it, and retires PyOpenGL's dispatch table for that context. It has to be called with the context still current and its window still whole, which is the only moment either of those is possible. Every backend does: glfw, glut, pygame, wx, egl and qt.

Application code that caches GL objects of its own registers to hear the same announcement. The callback takes no arguments and runs with the dying context current, so it may delete GL objects as well as forget them, and contextresources.context_key() tells it which context that is:

from OpenGLContext import contextresources

@contextresources.on_context_lost
def drop_my_cached_objects():
    ...

Registering the same callable twice registers it once, and one cache raising does not stop the rest from being told. The registry holds a strong reference for the life of the process, which is right for a module-level cache registering at import; a callback bound to something shorter-lived hands itself back with contextresources.forget_context_lost( callback ).

A cache holds one entry per context, not one entry. Two windows draw alternately, so a single slot belongs to whichever drew last: every frame of every context would miss, rebuild what the other displaced, and abandon the displaced entry's GL objects in a context that is still alive and can no longer be reached to delete them. renderpass._passes, shaderpass._shader_programs, Teapot._buffers and shadertext._renderers are all mappings for that reason.

The test fixtures announce it too, which is what a suite needs: several hundred windows open and close in one process, and that is precisely the setting in which a driver reuses an address.

The ViewPlatform and ViewPlatformMixin classes provide a simple navigation interface using the keyboard arrow keys (walk/fly) and the ALT-arrow keys (pan/slide), plus the three examine gestures — right-drag to orbit, middle-drag to pan, wheel to move toward or away. Those are TurntableOrbit driven by ExamineManager; see Examining.

An application wanting more than that declares movement modes on its ContextDefinition: walking, flying, swimming and first-person mouse-look as scenegraph nodes, each with its own speeds and key bindings, driven from sampled input rather than from events so several inputs act in one frame. See Movement Modes & Navigation. A context that declares none keeps the older navigation untouched.

An application that needs a screen over the running world — rendering settings, key rebinding, a licence notice, a console — mixes in ui.overlay.OverlayMixin, which adds a stack of panels, routes input to the topmost one and draws it after the frame. While a modal panel is up the world hears nothing at all, including the input sampler — and a press the overlay took takes its release with it, so the keystroke that closes the last panel does not also reach the world. Every switchable rendering feature is a field on the ContextDefinition, read through renderoptions, so the settings screen is generated from those fields rather than hand-written. The window's height picks the font size and everything else is measured against it, so the interface is the same size in the eye at 1080p and at 4K. See Overlay UI.

Finally, at the top-level, we have a number of utility functions and modules, including drawcube (a testing function), Quaternion,utilities, vector utilities, and triangle utilities.

Rendering Passes

The passes package holds the current rendering system. A single FlatPass observes the scenegraph and, each frame, draws it in a fixed sequence -- background, opaque, transmissive, transparent, selection and overlay -- rather than traversing it once per rendering mode. The base class (passes/_flat.py) carries two code paths, chosen by one flag:

Shader programs are managed by VRML97ShaderProgram (passes/shaderpass.py), which compiles the lit, unlit, vertex-colour, point, line and shadow-depth programs and assembles them from the GLSL sources in the shaders/ directory (the .vert/.frag files plus shared _*.glsl include files, spliced together at compile time). Built on top of this are:

Loaders and Command-Line Tools

The loaders package reads external model formats into the scenegraph:

The viewer package is the reusable viewer itself — loading a scene without freezing the window, default lighting, auto-framing, cameras and animations, the caption, screenshots, a library of samples to open, and rendering one settled frame to a file. An application embeds viewer.sceneviewer.ViewerContext and configures it with a ViewerOptions, which is the same object the command line fills in (see Embedding the viewer). Its parts — adapters, asyncscene, framing, environment, caption, capture, library, menu — are usable individually by a context that is not a viewer.

What format a source is in is decided by an adapter registered under plugins.Adapter, alongside the loader, context and node registries, so the dispatch is data rather than a chain of tests and a third party adds a format without touching the viewer (Adding a format).

The bin package provides console commands, registered as scripts when the package is installed: oglc-view (the viewer plus a command line, for every format), oglc-terrain, oglc-gltf-demo, oglc-gltf-regression, oglc-ui-demo, oglc-character-sheet, oglc-test and oglc-lorentz. oglc-gltf, oglc-vrml and oglc-tiles are deprecated aliases for oglc-view.

The packaging package is for shipping an application built on the engine to somebody who has no Python — a frozen bundle, or a native package holding its own interpreter. It is the engine's answers to what neither can work out alone: which of its modules are reached by name rather than by import (the PyInstaller hooks in __pyinstaller, which PyInstaller finds by entry point), which windowing toolkits an application is not using, and which libraries it asks the operating system for. It carries one console command, oglc-deb. Nothing in it is imported at run time and nothing in it draws (see Packaging an application).

Scenegraph Rendering

The scenegraph and scenegraph.text packages provide a set of Python classes which render certain common types of geometry, materials, textures and grouping nodes (modeled loosely after VRML 97 nodes). This allows you to create "retained mode" scenes for rendering in your contexts (note that these classes are largely divorced from the internals of the context). You'll find Transform (including integer "names" reported during selection), Shape,Material,ImageTexture, and Light nodes which are similar to their VRML 97 namesakes.  The ArrayGeometry class handles the rendering of all of IndexedFaceSet, IndexedLineSet and PointSet, with the modules of those names simply instantiating ArrayGeometry instances with the appropriate parameters.  The text package provides basic text rendering in 3D or 2D forms using TTFQuery or one of the GUI library engines.

Alongside the VRML97-style nodes, the scenegraph includes PBRMaterial and a PBRMesh geometry (scenegraph/pbrmaterial.py), the metallic/roughness material and mesh produced by the glTF loader and rendered by the PBR pass. Legacy Material nodes are converted to the same model when the PBR renderer is active, so both kinds of content share one pipeline.

Events and Selection

The events package implements a simple cross GUI-library event generation and handling system.  Each GUI library defines subclasses of the major event and event handler classes.  These subclasses translate from native events to OpenGLContext events. The event handler classes (which are mixin classes) are then included in the GUI library's Context class to provide the event handling interfaces.

Mouse events reach the application through the pick queue: a backend adds one with Context.addPickEvent and the selection pass dispatches it once it has resolved what the pick point is over. The queue is a mapping keyed by Event.getPickKey, so events that are the same news twice within one frame cost one dispatch. The wheel is the exception: a notch arrives as a press and release of button 3 or 4 (mouseevents.WHEEL_UP and WHEEL_DOWN, the X11 numbering), it is an increment rather than a state, and its pick key is distinct per notch so none is dropped. GLFW reports scrolling on a callback of its own in offsets and translates to those buttons; see the overlay UI documentation.

Which handler holds a key

One, and it is the last registered: a key is identified by (name, state, modifiers) and registering a second handler for the same triple replaces the first. So the two places a context binds keys run in a deliberate order, and Context.__init__ runs them that way:

  1. setupDefaultEventCallbacks — what a key does when nobody has said otherwise: Escape, the arrow-key navigation, right-drag to examine, PageDown to cycle viewpoints, Alt+F for the developer overlay, and F2 or Alt+S for a screenshot.
  2. setupCallbacks — what a key does in this context. It lands on top, so an application that wants a key the framework also binds simply binds it.

Modifiers are a three-tuple in the order (shift, control, alt). A binding that asks for the wrong slot is not an error — it registers, and the key silently never fires.

Handlers are held by weak reference, so the caller must keep the callback alive; a bound method of a live object is the normal choice, and one with no other reference is collected the moment the registration returns, leaving the key silently dead.

Presenting a frame, and reading it

A render pass finishes a frame by calling Context.presentFrame, which is the last moment that frame can be read: SwapBuffers hands the back buffer to the driver, which recycles it, so a read afterwards returns an older frame. Anything that has to see what the viewer saw — a screenshot, a still capture, a recording — reads it from there.

def presentFrame(self):
    self.tickRecording()              # before the swap: this is the frame
    return super().presentFrame()

SwapBuffers is one thing: the method each GUI backend implements to put the buffer up. Override presentFrame for work that belongs to the frame, and SwapBuffers only when writing a backend.

The screenshot key

Every context binds F2 (and Alt+S) to requestScreenshot without being asked, so a program built on OpenGLContext has a screenshot key with no configuration. The key asks: it raises a flag and requests a redraw, and the picture is taken from presentFrame, for the reason above. The redraw is what makes the key work on a still scene, where there would otherwise be no next frame.

The file goes to the user’s picture folder — ~/Pictures or whatever the desktop, the Finder or the Windows shell says that is — named for the window title: GLinting-Steel-0001.png. The count rises to the first name nothing is using, so pressing the key twice keeps both shots. A machine with no home directory writes to the working directory instead.

AttributeWhat it does
screenshotKeyThe key to bind, '<F2>' by default. '' binds none, which is how an application keeps F2 for something of its own.
screenshotTemplateHow the file is named. %(name)s is the window title, %(count)04i the number that rises until the name is free.
screenshotDirectory()Where a screenshot with no path of its own is written. Override it to file them somewhere else.
OnSaveImage()Takes the picture now, from whatever is in the back buffer. Call it from presentFrame, or pass template= to write a named file.