The viewer

oglc-view opens a 3D scene — a glTF model, a VRML97 world, a Wavefront OBJ, a streamed 3D Tiles dataset — renders it with the core-profile PBR renderer and lets you look around it or walk through it. Which format a source is in is worked out from the source, not from which command you typed, so there is one viewer and every format gets everything it can do.

Run it with nothing to open and it shows its menu: a library of sample models and worlds with pictures to browse, and a box to type an address into — a viewer launched from a desktop has no command line to pass a URL on. The same class is a reusable component — see Embedding.

Opening something

oglc-view                       # the launch menu and the library
oglc-view path/to/model.glb
oglc-view path/to/world.wrl
oglc-view path/to/model.obj
oglc-view path/to/tileset.json
oglc-view https://example.com/model.glb
GLTF=path/to/model.gltf oglc-view

The viewer selects the core profile, the PBR renderer, the GLFW backend and shadows for you, so no environment variables are needed.

What it can open

SourceAdapterWhat it is
.gltf .glbgltf A glTF 2.0 model: centred and framed, with its own cameras and animations. See Loading glTF.
.wrl .wrz .vrml .wrl.gzvrml97 A VRML97 world: shown where it was authored, keeping its own sky, its own lights and its own Viewpoint nodes.
.objobj A Wavefront model. It carries no lights, cameras or sky, so the viewer supplies all three.
tileset.json, or any JSON with both asset and roottiles3d An OGC 3D Tiles dataset, streamed and refined as you move. See Terrain & 3D Tiles.

A source served from a path with no suffix — /download?id=3 — can be named explicitly with --format gltf.

A binary .glb is self-contained, so URLs to one work directly. A non-binary .gltf usually references external .bin and texture files, by URI relative to the document; a URL keeps the document's own location and resolves them against it, so a remote multi-file model opens with its geometry and textures intact. See Loading glTF.

A streamed dataset has knobs the other formats do not, and they are on the one command line: --sse (screen-space error target in pixels; lower is more detail), --memory (resident tile budget in MiB), --no-recenter and --cache-dir. A format that is read once ignores them.

Framing a model, and parts left out of the frame

A model with no camera of its own is centred and framed automatically: the camera backs off far enough for the model's bounding sphere to fill the field of view, raised a little and tilted down so it is seen from slightly above. --margin, --elevation and --tilt adjust that fit, and --eye X,Y,Z with --look-at X,Y,Z replaces it outright.

The sphere is fitted to the model, which is not always everything the file draws. Exported models quite often carry a part or two stranded far outside themselves — a decal left behind at a hundred times its scale, a duplicate forgotten at the far end of the file's coordinate space. Framing those too would stand the camera hundreds of model-widths back, and the model would be a speck in the middle of an empty frame. So a part is left out of the fit when both of two things are true: the file's whole extent is more than four times that of the nine-tenths of the model lying closest together, and the part sits beyond an empty shell — more than twice as far out as everything nearer than it. A model that merely thins out towards its edges fails the second test, and a scene that genuinely is two things far apart fails the first, so neither is cut.

Nothing is hidden by this: a stray is still drawn, and walking or flying out to it reaches it as usual. It simply does not decide where the camera starts. When any part is left out the viewer says so on startup, with how many and how far out they are, so a file that looks incomplete can be recognised as one:

Framed on the model: 24 part(s) of this file sit up to 102 times its size
away and start out of view.

The rule is OpenGLContext.loaders.gltf.transforms.framing_bounds(), and its three constants — STRAY_RATIO, STRAY_CROWD and STRAY_GAP — are the numbers quoted above. It applies to glTF sources; the other formats frame their whole extent.

The old command names

oglc-gltf, oglc-vrml and oglc-tiles still work: each runs oglc-view and prints a line saying so. They will be removed after one release cycle. Every option they took is still accepted.

oglc-vrml's --shaders / --no-shaders switches are gone. They reached into the render pass from inside Redraw to turn shader rendering on; the viewer renders through the core-profile PBR pass as a matter of course. A world that genuinely wants the compatibility pipeline gets it where every other renderer switch lives:

OPENGLCONTEXT_PROFILE=compatibility oglc-view world.wrl

Controls

Moving

What a right-drag orbits

The point you clicked on, when you clicked on the model: examining the thing you touched is the whole gesture. A click that hits nothing still resolves to a world point — the far plane, an order of magnitude further out than anything on screen — so a picked point is used only where it lies within reach of the scene's bounding sphere (one and a half times its radius, which is generous because a bounding sphere already overstates a model's extent).

Otherwise the pivot comes from the scene itself. Looking at a model from outside, it is the middle of the model, whatever the model's size or where it sits; standing inside a scene — a building you are walking through — orbiting the far wall would swing you round the room, so the pivot is a point ahead of the camera instead, at half the scene's radius. A viewer with nothing loaded pivots a fixed distance ahead.

A drag from one side of the window to the other is half a turn. The trackball measures a drag as the fraction of the way from where it started to the edge of the window, so a full turn per window made every small nudge a large swing; the distance to the pivot never changes, whatever the drag.

The scene

Each of these does its thing once, on the key coming back up: a held key repeats around twenty times a second, which for these would be twenty models loaded or twenty cameras past the one wanted. They are declared as a table, SceneViewerMixin.viewerKeys, of KeyBinding(name, method, description, modifiers, state) — a subclass adding a key of its own extends that rather than overriding setupCallbacks, and the description says what the key does in a form fit to list. Modifiers are (shift, control, alt), in that order; the F6 controls screen rebinds the navigation commands, which are a separate set.

Screens

KeyWhat it raises
F1The library: what there is to open.
F10Settings — the same page every program here shows.
F6Controls: rebind any key.
F2Save a screenshot (a dated PNG in the working directory).
EscapeThe menu, with Resume first and Quit below it. Escape never ends the session by itself: it is the key people press to back out of something, and a loaded world is too expensive to throw away without being asked. Escape again resumes.
Alt+FThe developer overlay. The viewer adds a Scene section to it: the source, which adapter read it, the radius the framing came from, which camera is bound, the animation and how you are moving.

These are the keys twig-bb uses, deliberately: someone who has used one of these programs knows the other.

Every screen is usable from the keyboard alone: the up/down arrows or Tab/Shift-Tab move between items, Space or Return presses the one focused, Return alone presses the screen's primary action, and Escape leaves it.

The library

oglc-view with nothing named is not a usage message. It opens a menu, and from it a shelf of models and worlds shown by their own pictures — because what tells one sample from another is what it looks like, and a list of names like MetalRoughSpheresNoTextures makes you open each in turn to find out which is which.

The shelf is derived from the demo roster the capture harness already uses (OpenGLContext.loaders.gltf_demos), so the library and the reference captures cannot drift on how a model has to be shown: the yaw it faces at, the backdrop its materials need, whether it is meant to be walked. It is grouped into Models, Materials (metals and glass, which need an environment), Scenes (authored cameras or an interior), Local builds and, last, Feature tests.

This project ships no worlds of its own, so there is no Worlds shelf until an application adds one — tests/wrls is test data, not content. world_entries() turns a directory of them into entries for an application that has some.

Preview pictures are the Khronos reference screenshots, fetched once and cached on disk. Being offline costs pictures and nothing else.

The Feature tests shelf is last and holds the bulk of the Khronos set: entries that exist to exercise one glTF feature — a bare triangle, a sparse accessor, the Compare* grids — rather than to be looked at. Which is which is a field on the shared demo table, so this shelf and oglc-gltf-demo's browsing order cannot disagree.

The band's arrows look; clicking a picture is what opens it.

Choosing something opens it into the running window — a new adapter, a new scenegraph, new framing — without tearing the context down. Loading happens in the background, and a load that a later one has overtaken is dropped, so clicking quickly through the shelf is safe.

Your own shelf

An application curates its own by overriding one method:

from OpenGLContext.viewer import ViewerContext
from OpenGLContext.viewer.library import Entry, Library, default_library

class MyViewer( ViewerContext ):
    def viewerLibrary( self ):
        return default_library().extend([
            Entry(
                name = 'Our product',
                source = 'https://example.com/product.glb',
                category = 'Ours',
                preview = 'https://example.com/product.jpg',
                note = 'the current revision',
                options = {'physics': True, 'yaw': 0.4},
            ),
        ])

An entry's options are ViewerOptions field names, applied when it is opened and not carried over to the next one.

Hundreds of entries

The band shows five at a time out of however many there are, wrapping, so a shelf of several hundred lays out as fast as one of three; the << and >> buttons move a bandful at a time. Pictures are decoded off the render thread and uploaded a couple per frame, and the least recently used are dropped once a texel budget is reached — see OpenGLContext.ui.pictures.

Capturing a frame

oglc-view model.glb --capture shot.png --size 1100x680
oglc-view model.glb --camera aerial --capture shot.png --capture-delay 0.5
oglc-view model.glb --list-cameras

--capture renders the scene, waits for it to settle — a wall-clock delay and a minimum frame count, so the adaptive analytic-sky IBL has converged — writes the PNG and exits. The captured frame carries no caption and no developer overlay.

It also renders without mapping a window and with vsync off. A mapped surface throttles the buffer swap to the compositor's frame callback, and with nothing on screen consuming frames the swap never returns; a hidden window renders and reads back identically. Both are overridable (OPENGLCONTEXT_HIDDEN=0), since watching a capture happen is how you find out why it looks wrong.

Embedding the viewer

Everything the command does — loading without freezing the window, the default light rig, auto-framing, the scene's own cameras and animations, the caption, screenshots, the library, walking — is the reusable OpenGLContext.viewer package, not the script. An application gets all of it by subclassing ViewerContext and saying what it wants with a ViewerOptions. There is no command line involved:

from OpenGLContext.viewer import ViewerContext, ViewerOptions

class MyViewer( ViewerContext ):
    options = ViewerOptions(
        source = 'model.glb',       # a path, a URL, or None for the library
        physics = True,             # walk it, rather than fly around it
        background = 'sky',
    )

MyViewer.ContextMainLoop()

ViewerOptions is a dataclass holding every knob the viewer has, and it is the same object the command line fills in — argparse populates one as its namespace — so the defaults are written once and a flag can never mean something different from the field. The fields are grouped as: the source and format; the cameras (camera, no_cameras); auto-framing (yaw, margin, elevation, tilt, eye, look_at); lighting and environment (lights, shadows, ibl_intensity, environment, background); animation (animate, animation, anim_time, turntable, no_rotate); physics; and the window and frame (size, capture, capture_delay, frames).

The seams worth overriding

MethodWhat it decides
prepareSource() Where the scene comes from. A browser with no single source overrides this to do nothing.
loadScene() Produce the scene. Runs on a worker thread, so it must not touch GL — that is what keeps the window drawing through a download.
requestInitialScene() What to load first.
buildScenegraph( scene ) Turn a loaded scene into self.sg. Called again for each new scene, which is what makes swapping possible.
onSceneReady() Just after a scene is built, on the render thread.
viewerLibrary() What this viewer offers to open.
buildPhysicsWorld() Where the collision world comes from, if not from self.sg. See Walking any scene.

openSource( source ) and openEntry( entry ) swap the scene in a running viewer.

The parts, separately

ModuleWhat it is
viewer.adaptersWhat a source is and how to read it, keyed by suffix and content type.
viewer.optionsEvery knob, in one dataclass that argparse also fills in.
viewer.libraryThe shelf: entries, categories and previews.
viewer.menuThe launch menu and the browse screen, as plain panels.
viewer.screensWhich key raises which screen, and what happens when one is answered.
viewer.asyncsceneLoading off the render thread, format-neutral.
viewer.framingWhere to put a camera to see a thing — pure arithmetic, no GL.
viewer.environmentThe sky, and the skybox its metals reflect.
viewer.captionThe line or two over the frame, as a HUD layer.
viewer.captureOne settled frame to a file.
viewer.debugThe developer overlay's Scene section.

Walking is not in this package at all: it is a capability of every interactive context, in OpenGLContext.move.physicswalk.

Adding a format

A format is an adapter: a class that reads one kind of source and answers the handful of questions asked of every scene. Nothing in the viewer changes.

from OpenGLContext.viewer.adapters.base import SceneAdapter, ViewerScene

class STLAdapter( SceneAdapter ):
    name = 'stl'
    recentres = True         # a model may be moved to the middle of the frame

    def load( self, source ):
        # Runs on a worker thread: no GL here.
        return ViewerScene( group = ..., center = ..., radius = ... )

Register it alongside the loader, context and node registries, against the suffixes and content types it handles:

from OpenGLContext.plugins import Adapter
Adapter( 'stl', 'mypackage.stl.STLAdapter', ['.stl', 'model/stl'] )

What a ViewerScene answers: group (the renderable root), center and radius (the bounding sphere the camera is framed against), strays and stray_reach (how much the sphere leaves out and how far out it goes — see Framing a model; both 0 for a loader that frames everything), viewpoints and cameras, animations and player(), and exposure. scene_bounds() in the same module works the bounding sphere out of a scenegraph for a format whose loader does not.

Two flags are the whole of what the viewer asks about a format:

A scene that brings its own Background does not get a second one — two of them is not two skies, it is a fight over which is bound — and one that brings its own lights is not re-lit. Both are detected from the scene rather than declared, so they are true of any format.