Physically Based Rendering

Physically based rendering (PBR) is a way of describing materials by their physical properties -- how metallic a surface is, how rough or polished, what colour -- rather than by hand-tuned lighting numbers. Describe a material once and it looks right under any lighting. OpenGLContext's PBR renderer uses the same material model as glTF 2.0 and most modern engines, so models authored in tools like Blender look the way their authors intended. This document is about using it: what the material model lets you do, and how to build the look you want.

PBR render of the glTF DamagedHelmet sample

The helmet above uses every part of the model at once: a colour map, metal and roughness, surface-detail (normal) mapping, a glowing (emissive) display, environment reflections and a cast shadow -- all from a single material.

Turning It On

The easiest way to see the PBR renderer is the oglc-view viewer, which switches everything on for you:

oglc-view path/to/model.glb

To use PBR for your own scene, set two environment variables on a core-profile context:

OPENGLCONTEXT_PROFILE=core OPENGLCONTEXT_RENDERER=pbr OPENGLCONTEXT_BACKEND=glfw \
    python your_script.py

OPENGLCONTEXT_PROFILE=core selects the shader pipeline and OPENGLCONTEXT_RENDERER=pbr chooses the PBR renderer. If PBR cannot be started for any reason, the plain core renderer is used instead.

The Material Model: Two Dials

Most of a PBR material comes down to a colour and two dials. The image below is the MetalRoughSpheres sample: metalness changes along one axis, roughness along the other.

A grid of spheres from metal to non-metal and smooth to rough

That is enough to describe a huge range of materials. A polished chrome ball is metalness 1, roughness 0. A rubber ball is metalness 0, roughness 0.9. A glossy plastic toy is metalness 0, roughness 0.2.

Material Recipes

Starting points for common looks. Adjust to taste, and add a normal map for fine surface detail.

Look Metalness Roughness Extras
Polished metal / chrome1.00.05 - 0.15base colour tints it (gold, copper)
Brushed / satin metal1.00.3 - 0.5normal map for the brush lines
Glossy plastic0.00.1 - 0.3bright base colour
Matte plastic / rubber0.00.7 - 1.0
Polished stone / marble0.00.2 - 0.4normal map; optional clearcoat
Rough stone / concrete0.00.8 - 1.0normal + occlusion maps
Leather0.00.5 - 0.7normal map for the grain
Wood (varnished)0.00.3 - 0.5colour map; optional clearcoat
Car paint0.00.4clearcoat 1.0 (see below)
Velvet / fabric0.00.8sheen (see below)
Glass / water0.00.0 - 0.2transmission (see below)

Texture Maps: Painting the Dials

A single colour and two numbers give a whole object one uniform look. To vary the look across the surface -- rust here, clean metal there, a label on a bottle -- you paint the properties into texture maps. A mesh carries texture coordinates (UVs) that say where each point of the surface lands on a flat image; the renderer looks up each map at those coordinates. Each map drives a specific role:

You do not have to supply every map. A material can be just a base colour and two dials, or a full set of maps, or anything between. Any property with a map still has a plain factor too, and the two multiply -- so a white base-colour map with a red base-colour factor gives a red surface.

Each map can name its own UV set. glTF gives every texture reference a texCoord of 0 or 1; the loader records which channels asked for the second set in the material's texCoordMask, and the shader samples each channel from the set it named. That is what lets a wall carry its brick pattern tiled many times over and its baked lighting stretched once across the whole surface. The mesh has to supply the second set (TEXCOORD_1) for the channels that ask for it; where it does not, those channels sample at the origin of their map and the surface comes out one flat colour.

KHR_texture_transform (offset/scale/rotate of the coordinates) is honoured, including its own texCoord override. One transform is stored per material and applied to whichever channels carry the extension — see Surfaces that move for how to choose them. A material giving two of its textures different transforms gets the last one for both.

Glass, Clearcoat and Fabric

Three optional effects extend the basic model. They are always available; a material simply sets the relevant properties.

A dish of olives under a glass cover, light passing through the glass

Emissive (glow) is part of the basic model: set emissiveColor for a surface that gives off light of its own, and emissiveStrength to push it brighter than white for a bloomed, hot look.

On software rasterizers, or with OPENGLCONTEXT_TRANSMISSION=blend, glass falls back to simple alpha blending instead of true refraction.

Where Materials Come From

Most PBR materials arrive ready-made inside a glTF model authored in a tool such as Blender -- you load the file and the materials come with it. You can also build a material in Python with a PBRMaterial node and attach it to a Shape through an Appearance:

from OpenGLContext.scenegraph.pbrmaterial import PBRMaterial
from OpenGLContext.scenegraph.basenodes import Shape, Appearance

# brushed gold
gold = PBRMaterial(
    baseColor = (1.0, 0.78, 0.34),
    metallic  = 1.0,
    roughness = 0.35,
)

# glowing green sign
sign = PBRMaterial(
    baseColor     = (0.1, 0.1, 0.1),
    metallic      = 0.0,
    roughness     = 0.6,
    emissiveColor = (0.1, 1.0, 0.2),
)

# clear glass
glass = PBRMaterial(
    baseColor    = (1.0, 1.0, 1.0),
    metallic     = 0.0,
    roughness    = 0.05,
    transmission = 1.0,
    ior          = 1.5,
)

shape = Shape(appearance=Appearance(material=gold), geometry=my_geometry)

The commonly used fields are baseColor, metallic, roughness, emissiveColor, emissiveStrength, clearcoat / clearcoatRoughness, sheenColor / sheenRoughness, transmission, ior, and the transparency controls alphaMode (OPAQUE, MASK or BLEND) and alphaCutoff. Legacy VRML97 Material nodes are converted to metallic/roughness automatically, so a mixed scene works without changes.

Textures are supplied through a textures dictionary keyed by channel (baseColor, metallicRoughness, normal, occlusion, emissive), each a PBRTexture wrapping a PIL image; base colour and emissive images are sRGB, the rest linear. The glTF loader builds these for you when it imports a model.

What Feeds the Shader

A PBRMaterial supplies the surface properties; the scene supplies the lighting. The shader combines them into the final colour of each pixel:

Material (PBRMaterial) Base colour surface colour / metal reflection tint Metalness metal ↔ non-metal Roughness glossy ↔ matte Normal map fine surface detail (bumps) Occlusion shadow in creases Emissive self-glow (screens, LEDs) Optional lobes clearcoat · sheen transmission (glass) · ior PBR shader Cook-Torrance + image-based lighting Scene lighting lights · environment · shadows Shaded pixels

Each knob on the left changes one aspect of the surface; the scene's lights, environment and shadows do the rest. The table below lists every property you can set.

Every Knob

Surface basics

PropertyDefaultWhat it controls
baseColor(1, 1, 1)the surface's own colour; tints a metal's reflection
metallic1.0metal (1) versus everyday material (0)
roughness1.0polished/glossy (0) versus matte (1)
emissiveColor(0, 0, 0)colour the surface gives off on its own
emissiveStrength1.0multiplier to push emissive brighter than white
normalScale1.0strength of the normal (bump) map
occlusionStrength1.0strength of the baked occlusion map
ior1.5index of refraction; base reflectivity and how much glass bends the view
specular / specularColor1.0 / (1,1,1)fine control of the non-metal highlight strength and tint

Optional lobes (each off at 0)

PropertyDefaultWhat it controls
clearcoat0.0strength of a clear glossy layer on top (car paint, varnish)
clearcoatRoughness0.0how sharp the clearcoat's highlight is
sheenColor(0, 0, 0)colour of the soft fabric rim-glow (velvet, satin)
sheenRoughness0.0how spread-out the sheen is
transmission0.0how see-through the surface is (glass, water)
thickness0.0volume thickness for tinting transmitted light
attenuationColor / attenuationDistance(1,1,1) / 0colour picked up passing through the volume, and over what distance

Transparency and rendering

PropertyDefaultWhat it controls
alphaModeOPAQUEOPAQUE, MASK (hard cutout) or BLEND (see-through)
alphaCutoff0.5threshold used in MASK mode
transparency0.0legacy opacity (1 = fully transparent)
doubleSidedfalsedraw back faces as well as front
unlitfalseshow the base colour flat, ignoring lighting

Texture maps -- supplied through the textures dictionary, keyed by channel: baseColor, metallicRoughness, normal, occlusion, emissive. Each map paints its property across the surface (see Texture Maps above); where a map and a factor both exist, they multiply.

Defaults follow the glTF 2.0 spec, which is why metallic and roughness default to 1.0: a bare material with no maps is a rough non-reflective metal until you set them. Authoring tools almost always set both.

You can readily find PBR textures online with CC0 or similar licenses that you can use in your editor (e.g. Blender). PBR materials are pretty much a base feature of most 3D engines these days.

Lighting the Scene

A material only looks right if the scene is lit. The PBR renderer lights surfaces two ways at once: direct lights (sun, lamps, spotlights) and the surrounding environment.

Environment Lighting (Reflections and Ambient)

Real surfaces reflect their surroundings and pick up soft colour from the whole scene, not just from direct lights. The renderer supplies this "image-based lighting" from a built-in studio environment, so metals have something to reflect and matte surfaces are softly lit even in shadow. It is on by default. You can adjust or disable it:

Shadows

The PBR renderer casts dynamic shadows, on by default. Directional lights (a sun) shadow the whole scene, spot lights cast a cone, and point lights shadow in every direction; turn them off with OPENGLCONTEXT_SHADOWS=0 or soften spot shadows with OPENGLCONTEXT_SHADOWS_SOFT=1.

Shadows are shared with the VRML97 core lighting shader -- the same system serves both -- so they have their own document: see Shadows for the full discussion and controls.

Under the Hood: the Shading Math

This section is a short overview for the curious; you do not need it to use the renderer. For a full line-by-line walkthrough of the fragment shader -- every texture, uniform and buffer it reads, and how each lobe is sampled and combined -- see The PBR Uber-Shader, Step by Step.

Direct light is evaluated with the Cook-Torrance microfacet model: a specular term plus a Lambertian diffuse term. The specular term is the product of three functions, D * V * F:

The diffuse term is albedo / π, scaled by (1 - F) to conserve energy and by (1 - metalness) because metals have no diffuse reflection. The BRDF works in terms of alpha = roughness²; the perceptual roughness you set is squared exactly once, and the same convention is shared between the direct-lighting and environment-lighting paths (both use one include, shaders/_brdf_inc.glsl) so the two halves integrate the same lobe.

Environment lighting uses the split-sum approximation: a prefiltered environment cube (one blur level per roughness), an irradiance cube for diffuse ambient, and a BRDF lookup table, all precomputed once from the studio environment.

Colour. Lighting is computed in linear colour. Base-colour and emissive texels are decoded from sRGB on input; the final colour is tone-mapped with an ACES filmic curve and re-encoded to sRGB. Because the shader encodes sRGB itself, GL_FRAMEBUFFER_SRGB is left disabled (see Core-Profile Rendering).

One shader for the scene. Clearcoat, sheen and transmission are branches inside a single "uber-shader" driven by uniforms that are constant per draw call, so the whole scene binds one program rather than switching shaders per material -- the switches are coherent and cheap. The three optional lobes are gated by compile-time defines (all on today) as a designed-in place to build a leaner shader for a constrained platform: chosen once per platform, never per material. Material factors are packed into a std140 uniform buffer, built once per material and reused across frames.

Fog: the medium the camera is standing in

Put a Fog node in the scene and the view fades into its colour with distance. That is the whole of the interface — the pass finds the bound node while it is gathering the frame, exactly as it finds the background:

from OpenGLContext.scenegraph.basenodes import Fog

Fog(color=(0.06, 0.20, 0.32),     # linear RGB
    visibilityRange=18.0,          # metres to total obscurity; 0 is off
    fogType='EXPONENTIAL')         # or 'LINEAR', the default

It is VRML97's own node, with the fields that specification gives it (ISO/IEC 14772-1 6.19). Being a node rather than a number on the context is the point: fog is a property of a place, so it binds and stacks like a viewpoint, and being under water, inside a smoke-filled room and out in clear air are three fogs in one scene with one in force.

Why there are two curves

They reach total obscurity at the same range and fade differently on the way, so choosing between them is choosing the shape and not the extent:

fogTypeFadesSuits
LINEAR in proportion to distance, from the very first metre haze, aerial perspective, dissolving the far edge of a terrain patch
EXPONENTIAL hangs back, then closes in a medium — water, smoke — where what is right in front of you is clear and the far wall is not

That difference matters more than it sounds. A linear fade tints the weapon in the player's hands as much as it tints the wall behind it, which reads as a coloured pane of glass laid over the screen rather than as being inside something. This is also why an underwater view should be a fog and not a full-screen overlay: an overlay has no depth in it at all.

Where it lands in the pipeline

The blend happens in linear HDR, before tone mapping, which is what keeps a fogged scene from posterising in the highlights. One uniform carries the scale for every mode: the reciprocal of the visible range, which the shader multiplies by the fragment's distance. A scene with no Fog in it — nearly every scene — is left pixel-identical, because the mode uniform stays at zero.

A transform above a Fog scales its visibilityRange, since the specification puts the range in the node's local coordinates; a model authored with its own fog therefore carries it when placed at a different size.

OpenGLContext/scenegraph/fog.py holds the node and the mode codes; the pass reads it in flateffects._FlatEffectsMixin.applyFog and it reaches the shader through PBRShaderProgram.set_fog(density, color, mode).

Surfaces that move

Three things can move a PBR surface at run time, and they cost very different amounts. Reaching for the cheapest one that will do the job is most of the performance story for animated materials.

1. The UV transform — one uniform

PBRMaterial.uv_transform is a 3×3 KHR_texture_transform matrix. Assigning it bumps the material's upload version, so the pass re-uploads its uniform block and nothing else happens. A scrolling conveyor belt, a rotating fan or a stretching liquid costs this and no more, however large the surface is.

material.uv_transform = [[1, 0, u_offset],
                         [0, 1, v_offset],
                         [0, 0, 1]]        # translation in the last COLUMN

Which channels it applies to is the high half of texCoordMask: the channel's low bit shifted by 8. Set it for the base colour and leave it clear for the lightmap, or a scrolling texture drags the baked lighting along with it and takes the shadows off the walls.

material.texCoordMask |= (1 << 8)     # baseColor transformed, lightmap not

2. Colour, opacity and texture — one uniform or one bind

baseColor, emissiveColor and transparency are ordinary material fields; assigning any of them re-uploads the block. Replacing material.textures with a new dict swaps a map — assign, do not mutate, or the version is not bumped and the old texture set is served.

3. The vertices — a per-frame CPU pass and a buffer re-upload

When the movement genuinely cannot be a matrix — a wave that heaves the geometry, a churn whose offset depends on where each vertex is — a mesh takes a surface deformer:

def churn(positions, normals, texcoords):
    return positions + wave(positions, clock.now), normals, texcoords

mesh.set_surface_deformer(churn)     # applies at once
...
mesh.refresh_surface()               # each frame; re-reads the deformer

This is a third stage on the same pipeline morph targets and skinning already use: rest arrays are kept, the deform chain is morph → skin → surface, a version counter bumps, and each per-context GPU object re-uploads the dynamic vertex buffers. Three properties are worth knowing:

A deformer that returns nothing usable leaves the mesh alone: content is not always well formed, and a bad effect should cost its effect rather than its surface.

scenegraph/pbrmesh.py: set_surface_deformer, refresh_surface, deforms_texcoords, and stage 3 of _apply_deform. The twig-bb drives all three of these from Quake 3 `.shader` scripts; twig_bb/animator.py is a worked example.