Core-Profile Rendering

OpenGLContext can render the same scenegraph two ways: through the legacy fixed-function pipeline (the compatibility profile), or through GLSL shaders on an OpenGL 3.3 core-profile context. This document describes the core-profile path: how it is selected, the passes it runs, the shader programs it manages, and the conventions a geometry node must follow to draw through it. The physically based renderer is a specialization of this same path.

Choosing a Profile

core is what a context asks for when nothing says otherwise: it requests an OpenGL 3.3 core-profile context and uses the shader-based render pass. It is the only path that works on platforms that have dropped fixed-function support, such as macOS, and the only one that draws the geometry nodes the glTF loader and the generators produce.

compatibility gives the fixed-function pipeline, for a program that draws with glBegin, the matrix stack, display lists, glMaterial/glLight or GLSL's gl_ModelViewProjectionMatrix. A program that needs it declares so on its Context class, where the requirement travels with the code:

class MyContext( BaseContext ):
    profile = 'compatibility'

The environment variable settles the profile for a whole run instead, which is what a CI job or a one-off comparison wants:

OPENGLCONTEXT_PROFILE=compatibility python your_script.py

Either way the profile is read once, before the window exists. See Saying which profile your program needs. All five backends create a core context.

The dispatch lives in passes/renderpass.py. When the context definition reports profile == 'core', the renderer instantiates the core FlatPass (from passes/flatcore.py); otherwise it uses the compatibility FlatPass (from passes/flatcompat.py). The choice is made per context, not per frame.

Compatibility vs. Core, side by side

Both profiles run through a FlatPass (see Flat Rendering). The base class in passes/_flat.py carries both code paths; the single flag use_shaders selects between them. The core pass sets use_shaders = True; the compatibility pass leaves it False.

  Compatibility
(flatcompat.py)
Core
(flatcore.py)
Selected by profile = 'compatibility', or OPENGLCONTEXT_PROFILE=compatibility default
Lighting fixed-function glLight*, glEnable(GL_LIGHTING) VRML97 lighting model in GLSL; light properties uploaded as uniforms
Materials glMaterial*, glColorMaterial material uniforms set on the shader program
Matrices glMatrixMode / glLoadMatrixf / glPushMatrix client-side node-path matrices uploaded as mat4 uniforms
Geometry vertex pointers, display lists VAOs and VBOs at fixed attribute locations
Selection / picking glColor4ubv back-buffer colour codes object-id written to a second render target (MRT), or the unlit program

Watching the Scenegraph

The flat pass does not traverse the scenegraph every frame. It observes the graph and keeps a flat list of paths to every renderable node. The SGObserver base class (in passes/_flat.py) connects to the scenegraph's change signals -- child added, child removed, Switch changed -- and rebuilds a NodePath for each affected node. A NodePath caches the combined transform matrix for the route from the root to that node, so per-frame rendering is a small number of iterations over a prepared list rather than a recursive traversal. This is the same mechanism described in Flat Rendering; the core pass reuses it unchanged.

A path lives exactly as long as the route it names. When a subtree leaves the graph its paths are invalidated and dropped from both records the pass keeps: the draw set it iterates, and the per-node index it uses to turn a change somewhere in the graph back into the paths running through it. That index holds a path for every integrated node, including nodes the pass draws nothing for, so the two are maintained together. A path left behind would keep alive every transform matrix cached against it, and each of those caches stays registered for the fields it depends on -- content streaming in and out of a world would then widen the set of receivers every change has to notify, and a frame would cost more the longer the session had run.

The Switch signal reports a change of child, not an assignment. whichChoice is commonly written every frame -- level-of-detail sets it from the viewer's distance -- and an assignment naming the child already being drawn is not a change: the signal is not sent, and a pass that hears one keeps the path it has rather than walking the subtree again. A node that switches its own children should follow the same rule, so that observers are woken for changes rather than for writes.

The Passes

On each visible frame the core FlatPass runs these steps in order:

The compatibility pass runs the analogous fixed-function sequence: legacy background, legacy lights, opaque, transparent.

sRGB Output

The shaders write colour that is already sRGB-encoded, so the framebuffer must not encode it a second time. On the first frame (with the context current) the pass disables GL_FRAMEBUFFER_SRGB once and leaves it off. It is a one-time call: nothing in OpenGLContext ever enables it, and some backends hand the application an sRGB-capable default framebuffer that would otherwise double-encode and wash the frame out.

Shader Programs

Core-profile rendering is managed by VRML97ShaderProgram (in passes/shaderpass.py), which compiles and owns a small family of programs, each for a different kind of geometry:

Background nodes carry their own small shader (vrml97_background.*). The PBR pass subclasses VRML97ShaderProgram and replaces only the main lit program with its Cook-Torrance shader, inheriting the rest.

Fixed Attribute Locations

A geometry node and a shader program meet at an attribute location number, and OpenGLContext/scenegraph/vertexsemantics.py is where that number is decided for the whole engine. The keys are glTF's vertex semantics, the vocabulary the loaders already speak:

Location Semantic Shader name
0 TEXCOORD_0 aTexCoord (vec2)
1 NORMAL aNormal (vec3)
2 POSITION aPosition (vec3)
3 TANGENT aTangent (vec4, w = handedness)
4 COLOR_0 aColor (vec4)
5–10, 14 — reserved for the per-instance inputs
11 TEXCOORD_1 aTexCoord1 (vec2)
12, 13 JOINTS_0, WEIGHTS_0 aJoints, aWeights (vec4 each, skinning)

A shader of your own may put an input the engine has no name for at location 15 or above (vertexsemantics.FIRST_FREE_LOCATION).

Which of those arrays a program has to have

An input nothing feeds is not a GL error: the draw goes ahead and every vertex reads the same default value. Most of those defaults mean something. The PBR program declares a tangent, a vertex colour, a second UV set and a skin, and reads each only where a uniform says the geometry brought it, so a box carrying a position, a normal and a texture coordinate draws correctly. The inputs whose default means nothing are the ones a geometry must supply, and each shader says which of its inputs those are at the declaration:

layout(location = 1) in vec3 aNormal;    // required: shading has no direction without it
layout(location = 3) in vec4 aTangent;   // optional: zero disables normal mapping

The first word of the declaration's own comment is the marker; required is the one that is read, and every other input carries optional and what its default means. shadersource.required_inputs(filename) collects them, a shader program answers for whichever of its programs is bound (VRML97ShaderProgram.required_inputs() — the depth pass asks for a position and nothing else), and geometryarrays.report_missing_inputs names a geometry that cannot feed one. It is said once per program and geometry rather than once a frame, and goes out through the same log as everything else that could not draw:

WARNING OpenGLContext.passes.renderfailures: Rendering finished with 1 node that could not draw:
    Sphere ?: MissingVertexInput: the shader cannot draw without aNormal, which this geometry does not supply (1 time, opaque pass)

An appearance's own GLSL is outside this. What a program can be drawn without is a fact about its source, and the engine has that only for the shaders it compiled, so nothing is reported against a Shader node's program.

A Shape whose appearance brings its own shader

Shader is a programmable substitute for Appearance: it carries a GLSL program instead of a material and a texture.

Shape(
    geometry = Sphere( radius=2 ),
    appearance = Shader( objects=[ GLSLObject( shaders=[ ... ] ) ] ),
)

The geometry and the program are written by different people, so something has to say which vertex array feeds which input, and that something is the table above. GLSLObject.compile binds the engine’s own attribute names to those locations before it links, so a shader that declares in vec3 aPosition; receives positions with nothing else said. A shader that calls an input something else says so with a ShaderInput:

GLSLObject(
    shaders = [ ... ],
    attributes = [
        ShaderInput( semantic='POSITION', name='vertexInput' ),
        ShaderInput( semantic='NORMAL',   name='surfaceNormal' ),
    ],
)

Entries apply over the engine’s own names, so renaming only the position still leaves normals and texture coordinates arriving by their usual names. A layout(location = ...) qualifier in the source outranks both (GLSL 3.30 4.3.8.2), so a shader that states its own locations keeps them.

The pass supplies its camera to any GLSLObject that names one of its matrices — mat_modelproj, mat_modelview, their inverses and transposes — so a shader declaring uniform mat4 mat_modelproj; gets one with nothing to upload; itp_modelview is the inverse-transpose model-view, whose upper-left 3×3 is the normal matrix. Everything else in the program is the shader author’s own, and the pass leaves it alone. During the selection pass the shape draws with the pass’s own program instead, so picking paints an id colour rather than whatever the appearance’s shader would.

Locations rather than names, because a Vertex Array Object records locations. A VAO built by looking each name up in one program is valid only for that program, so a mesh drawn by the lit program, the unlit program and the shadow depth pass would need three of them; with the locations fixed, one VAO per geometry serves every program that follows the table. A program that owns both its geometry and its shader — the overlay UI batcher, the particle system, the vegetation layers — is outside the agreement and numbers its own inputs, but it must not spell one of the names above and mean something else by it.

How Shaders Are Assembled

The shaders are not monolithic files. Common code lives in include files (_common_inc.glsl, _brdf_inc.glsl, _lights_inc.glsl, _shadow_inc.glsl, _cubemap_inc.glsl) that are spliced in at compile time. The preprocessing in shaderpass.py does two things:

Assembling shaders this way keeps a single source of truth for the lighting math and lets the same shader compile correctly across drivers with very different texture-unit budgets. See Physically Based Rendering for the BRDF include and the shadow budget in detail.

Drawing Through the Core Pass (for geometry nodes)

A geometry node checks mode.shader_mode and, when true, draws with VAOs/VBOs instead of fixed-function calls:

def render(self, mode=None, **kwargs):
    if getattr(mode, 'shader_mode', False):
        return self._render_shader(mode)
    # ... legacy fixed-function path ...

def _render_shader(self, mode):
    program = mode.shader_program
    program.use(lit=True)                 # or use_point(), use(lit=False), ...
    program.set_matrices(mode.matrix, mode.projection)
    # bind a VAO with attributes at the fixed locations above, then:
    glDrawArrays(GL_TRIANGLES, 0, count)

The Shape node sets up material and texture before calling the geometry's render(), so the geometry only has to supply vertex data and issue the draw. See the structural overview for how Shape, Appearance and geometry interact.