ZenGL

ZenGL is a minimalist Python module providing exactly one way to render scenes with OpenGL.

pip install zengl

ZenGL is …

  • high-performance

  • simple - buffers, images, pipelines and there you go

  • easy-to-learn - it is simply OpenGL with no magic added

  • verbose - most common mistakes are catched and reported in a clear and understandable way

  • robust - there is no global state or external trouble-maker affecting the render

  • backward-compatible - it requires OpenGL 3.3 - it is just enough

  • cached - most OpenGL objects are reused between renders

  • zen - there is one way to do it

class Context
Represents an OpenGL context.
class Buffer
Represents an OpenGL buffer.
class Image
Represents an OpenGL texture or renderbuffer.
class Pipeline
Represents an entire rendering pipeline including the global state, shader program, framebuffer, vertex state, uniform buffer bindings, samplers, and sampler bindings.

Concept

ZenGL provides a simple way to render from Python. We aim to support headless rendering first, rendering to a window is done by blitting the final image to the screen. By doing this we have full control of what we render. The window does not have to be multisample, and it requires no depth buffer at all.
Offscreen rendering works out of the box on all platforms if the right loader is provided.
Loaders implement a load method to resolve a subset of OpenGL 3.3 core. The return value of the load method is an int, a void pointer to the function implementation.
Virtualized, traced, and debug environments can be provided by custom loaders.
The current implementation uses the glcontext from moderngl to load the OpenGL methods.
ZenGL’s main focus is on readability and maintainability. Pipelines in ZenGL are almost entirely immutable and they cannot affect each other except when one draws on top of the other’s result that is expected. No global state is affecting the render, if something breaks there is one place to debug.
ZenGL does not use anything beyond OpenGL 3.3 core, not even if the more convenient methods are available. Implementation is kept simple. Usually, this is not a bottleneck.
ZenGL does not implement transform feedback, storage buffers or storage images, tesselation, geometry shader, and maybe many more. We have a strong reason not to include them in the feature list. They add to the complexity and are against ZenGL’s main philosophy. ZenGL was built on top experience gathered on real-life projects that could never make good use of any of that.
ZenGL is using the same vertex and image format naming as WebGPU and keeping the vertex array definition from ModernGL. ZenGL is not the next version of ModernGL. ZenGL is a simplification of a subset of ModernGL with some extras that was not possible to include in ModernGL.

Context

zengl.context(loader: ContextLoader) Context

All interactions with OpenGL are done by a Context object. There should be a single Context created per application. A Context is created with the help of a context loader. A context loader is an object implementing the load method to resolve OpenGL functions by name. This enables zengl to be entirely platform-independent.

zengl.loader(headless: bool = False) ContextLoader

This method provides a default context loader. It requires glcontext to be installed. ZenGL does not implement OpenGL function loading. glcontext is used when no alternatives are provided.

Note

Implementing a context loader enables zengl to run in custom environments. ZenGL uses a subset of the OpenGL 3.3 core, the list of methods can be found in the project source.

Context for a window

ctx = zengl.context()

Context for headless rendering

ctx = zengl.context(zengl.loader(headless=True))

Buffer

Buffers hold vertex, index, and uniform data used by rendering.
Buffers are not variable-sized, they are allocated upfront in the device memory.
vertex_buffer = ctx.buffer(np.array([0.0, 0.0, 1.0, 1.0], 'f4'))
index_buffer = ctx.buffer(np.array([0, 1, 2], 'i4'))
vertex_buffer = ctx.buffer(size=1024)
Context.buffer(data, size, dynamic) Buffer
data
The buffer content, represented as bytes or a buffer for example a numpy array.
If the data is None the content of the buffer will be uninitialized and the size is mandatory.
The default value is None.
size
The size of the buffer. It must be None if the data parameter was provided.
The default value is None and it means the size of the data.
dynamic
A boolean to enable GL_DYNAMIC_DRAW on buffer creation.
When this flag is False the GL_STATIC_DRAW is used.
The default value is True.
Buffer.write(data, offset)
data
The content to be written into the buffer, represented as bytes or a buffer.
offset
An int, representing the write offset in bytes.
Buffer.map(size, offset, discard) memoryview
size
An int, representing the size of the buffer in bytes to be mapped.
The default value is None and it means the entire buffer.
offset
An int, representing the offset in bytes for the mapping.
When the offset is not None the size must also be defined.
The default value is None and it means the beginning of the buffer.
discard
A boolean to enable the GL_MAP_INVALIDATE_RANGE_BIT
When this flag is True, the content of the buffer is undefined.
The default value is False.
Buffer.unmap()

Unmap the buffer.

Buffer.size

An int, representing the size of the buffer in bytes.

Image

Images hold texture data or render outputs.
Images with texture support are implemented with OpenGL textures.
Render outputs that are not sampled from the shaders are using renderbuffers instead.

render targets

image = ctx.image(window.size, 'rgba8unorm', samples=4)
depth = ctx.image(window.size, 'depth24plus', samples=4)
framebuffer = [image, depth]

textures

img = Image.open('example.png').convert('RGBA')
texture = ctx.image(img.size, 'rgba8unorm', img.tobytes())
Context.image(size, format, data, samples, array, texture, cubemap) Image
size
The image size as a tuple of two ints.
format
The image format represented as string. (list of image format)
The two most common are 'rgba8unorm' and 'depth24plus'
data
The image content, represented as bytes or a buffer for example a numpy array.
If the data is None the content of the image will be uninitialized. The default value is None.
samples
The number of samples for the image. Multisample render targets must have samples > 1.
Textures must have samples = 1. Only powers of two are possible. The default value is 1.
For multisampled rendering usually 4 is a good choice.
array
The number of array layers for the image. For non-array textures, the value must be 0.
The default value is 0.
texture
A boolean representing the image to be sampled from shaders or not.
For textures, this flag must be True, for render targets it should be False.
Multisampled textures to be sampled from the shaders are not supported.
The default is None and it means to be determined from the image type.
cubemap
A boolean representing the image to be a cubemap texture. The default value is False.
Image.blit(target, target_viewport, source_viewport, filter, srgb)
target
The target image to copy to. The default value is None and it means to copy to the screen.
target_viewport and source_viewport
The source and target viewports defined as tuples of four ints in (x, y, width, height) format.
filter
A boolean to enable linear filtering for scaled images. By default it is True. It has no effect if the source and target viewports have the same size.
srgb
A boolean to enable linear to srgb conversion. By default it is False.
Image.clear()

Clear the image with the Image.clear_value

Image.mipmaps(base, levels)

Generate mipmaps for the image.

base
The base image level. The default value is 0.
levels
The number of mipmap levels to generate starting from the base.
The default is None and it means to generate mipmaps all the mipmap levels.
Image.read(size, offset) bytes
size and offset
The size and offset, defining a sub-part of the image to be read.
Both the size and offset are tuples of two ints.
The size is mandatory when the offset is not None.
By default the size is None and it means the full size of the image.
By default the offset is None and it means a zero offset.
Image.write(data, size, offset, layer) bytes
data
The content to be written to the image represented as bytes or a buffer for example a numpy array.
size and offset
The size and offset, defining a sub-part of the image to be read.
Both the size and offset are tuples of two ints.
The size is mandatory when the offset is not None.
By default the size is None and it means the full size of the image.
By default the offset is None and it means a zero offset.
layer
An int representing the layer to be written to.
This value must be None for non-layered textures.
For array and cubemap textures, the layer must be specified.
The default value is None and it means the only layer of the non-layered image.
Image.clear_value
The clear value for the image used by the Image.clear()
For the color and stencil components, the default value is zero. For depth, the default value is 1.0
For single component images, the value is float or int depending on the image type.
For multi-component images, the value is a tuple of ints or floats.
The clear value type for the depth24plus-stencil8 format is a tuple of float and int.
Image.size
The image size as a tuple of two ints.
Image.samples
The number of samples the image has.
Image.color
A boolean representing if the image is a color image.
For depth and stencil images this value is False.

Pipeline

Context.pipeline(vertex_shader, fragment_shader, layout, resources, depth, stencil, blending, polygon_offset, color_mask, framebuffer, vertex_buffers, index_buffer, short_index, primitive_restart, front_face, cull_face, topology, vertex_count, instance_count, first_vertex, line_width, viewport) Pipeline
vertex_shader
The vertex shader code.
fragment_shader
The fragment shader code.
layout
Layout binding definition for the uniform buffers and samplers.
resources
The list of uniform buffers and samplers to be bound.
depth
The depth settings
stencil
The stencil settings
blending
The blending settings
polygon_offset
The polygon offset
color_mask
The color mask, defined as a single integer.
The bits of the color mask grouped in fours represent the color mask for the attachments.
The bits in the groups of four represent the mask for the red, green, blue, and alpha channels.
It is easier to understand it from the implementation.
framebuffer
A list of images representing the framebuffer for the rendering.
The depth or stencil attachment must be the last one in the list.
The size and number of samples of the images must match.
vertex_buffers
A list of vertex attribute bindings with the following keys:
buffer: A buffer to be used as the vertex attribute source
format: The vertex attribute format. (list of vertex format)
location: The vertex attribute location
offset: The buffer offset in bytes
stride: The stride in bytes
step: 'vertex' for per-vertex attributes. 'instance' for per-instance attributes

The zengl.bind() method produces this list in a more compact form.

index_buffer
A buffer object to be used as the index buffer.
The default value is None and it means to disable indexed rendering.
short_index
A boolean to enable GL_UNSIGNED_SHORT as the index type.
When this flag is False the GL_UNSIGNED_INT is used.
The default value is False.
primitive_restart
A boolean to enable the primitive restart index. The default primitive restart index is -1.
The default value is True.
front_face
A string representing the front face. It must be 'cw' or 'ccw'
The default value is 'ccw'
cull_face
A string representing the cull face. It must be 'front', 'back' or 'none'
The default value is 'none'
topology
A string representing the rendered primitive topology.
It must be one of the following:
  • 'points'

  • 'lines'

  • 'line_loop'

  • 'line_strip'

  • 'triangles'

  • 'triangle_strip'

  • 'triangle_fan'

The default value is 'triangles'
vertex_count
The number of vertices or the number of elements to draw.
instance_count
The number of instances to draw.
first_vertex
The first vertex or the first index to start drawing from.
The default value is 0. This is a mutable parameter at runtime.
line_width
A float defining the rasterized line size in pixels. Beware wide lines are not a core feature.
Wondering where the point_size is? ZenGL only supports the more generic gl_PointSize.
viewport
The render viewport, defined as tuples of four ints in (x, y, width, height) format.
The default is the full size of the framebuffer.
Pipeline.vertex_count
The number of vertices or the number of elements to draw.
Pipeline.instance_count
The number of instances to draw.
Pipeline.first_vertex
The first vertex or the first index to start drawing from.
Pipeline.viewport
The render viewport, defined as tuples of four ints in (x, y, width, height) format.
Pipeline.render()
Execute the rendering pipeline.

Shader Code

  • do use #version 330 as the first line in the shader.

  • do use layout (std140) for uniform buffers.

  • do use layout (location = ...) for the vertex shader inputs.

  • do use layout (location = ...) for the fragment shader outputs.

  • don’t use layout (location = ...) for the vertex shader outputs or the fragment shader inputs. Matching name and order are sufficient and much more readable.

  • don’t use layout (binding = ...) for the uniform buffers or samplers. It is not a core feature in OpenGL 3.3 and ZenGL enforces the program layout from the pipeline parameters.

  • do use uniform buffers, use a single one if possible.

  • don’t use uniforms, use uniform buffers instead.

  • don’t put constants in uniform buffers, use #include and string formatting.

  • don’t over-use the #include statement.

  • do use includes without extensions.

  • do arrange pipelines in such an order to minimize framebuffer then program changes.

Shader Includes

Shader includes were designed to solve a single problem of sharing code among shaders without having to field format the shader code.
Includes are simple string replacements from Context.includes
The include statement stands for including constants, functions, logic or behavior, but not files. Hence the naming should not contain extensions like .h
Nested includes do not work, they are overcomplicated and could cause other sorts of issues.
Context.includes
A string to string mapping dict.

Example

ctx.includes['common'] = '...'

pipeline = ctx.pipeline(
    vertex_shader='''
        #version 330

        #include "common"
        #include "qtransform"

        void main() {
        }
    ''',
)

Include Patterns

common uniform buffer

ctx.includes['common'] = '''
    layout (std140) uniform Common {
        mat4 mvp;
    };
'''

quaternion transform

ctx.includes['qtransform'] = '''
    vec3 qtransform(vec4 q, vec3 v) {
        return v + 2.0 * cross(cross(v, q.xyz) - q.w * v, q.xyz);
    }
'''

gaussian filter

def kernel(s):
    x = np.arange(-s, s + 1)
    y = np.exp(-x * x / (s * s / 4))
    y /= y.sum()
    v = ', '.join(f'{t:.8f}' for t in y)
    return f'const int N = {s * 2 + 1};\nfloat coeff[N] = float[]({v});'

ctx.includes['kernel'] = kernel(19)

Rendering to Texture

Rendering to texture is supported. However, multisampled images must be downsampled before being used as textures. In that case, an intermediate render target must be samples > 1 and texture = False. Then this image can be downsampled with Image.blit() to another image with samples = 1 and texture = True.

Cleanup

Clean only if necessary. It is ok not to clean up before the program ends.

Context.clear_shader_cache()

This method calls glDeleteShader for all the previously created vertex and fragment shader modules. The resources released by this method are likely to be insignificant in size.

Context.release(obj: Buffer | Image | Pipeline)

This method releases the OpenGL resources associated with the parameter. OpenGL resources are not released automatically on garbage collection. Release Pipelines before the Images and Buffers they use.

Utils

Context.info
The GL_VENDOR, GL_RENDERER, and GL_VERSION strings as a tuple.
zengl.camera(eye, target, up, fov, aspect, near, far, size, clip) bytes
Returns a Model-View-Projection matrix for uniform buffers.
The return value is bytes and can be used as a parameter for Buffer.write().
mvp = zengl.camera(eye=(4.0, 3.0, 2.0), target=(0.0, 0.0, 0.0), aspect=16.0 / 9.0, fov=45.0)
zengl.rgba(data: bytes, format: str) bytes
Converts the image stored in data with the given format into rgba.
zengl.pack(*values: Iterable[float | int]) bytes
Encodes floats and ints into bytes.
zengl.bind(buffer: Buffer, layout: str, *attributes: Iterable[int]) List[VertexBufferBinding]
Helper function for binding a single buffer to multiple vertex attributes.
The -1 is a special value allowed in the attributes to represent not yet implemented attributes.
An ending /i is allowed in the layout to represent per instance stepping.
zengl.calcsize(layout: str) int
Calculates the size of a vertex attribute buffer layout.

Image Formats

format

OpenGL equivalent

r8unorm

.

rg8unorm

.

rgba8unorm

.

bgra8unorm

.

r8snorm

.

rg8snorm

.

rgba8snorm

.

r8uint

.

rg8uint

.

rgba8uint

.

r16uint

.

rg16uint

.

rgba16uint

.

r32uint

.

rg32uint

.

rgba32uint

.

r8sint

.

rg8sint

.

rgba8sint

.

r16sint

.

rg16sint

.

rgba16sint

.

r32sint

.

rg32sint

.

rgba32sint

.

r16float

.

rg16float

.

rgba16float

.

r32float

.

rg32float

.

rgba32float

.

rgba8unorm-srgb

.

bgra8unorm-srgb

.

stencil8

.

depth16unorm

.

depth24plus

.

depth24plus-stencil8

.

depth32float

.

Vertex Formats

shorthand

vertex format

OpenGL equivalent

1f

float32

.

2f

float32x2

.

3f

float32x3

.

4f

float32x4

.

1u

uint32

.

2u

uint32x2

.

3u

uint32x3

.

4u

uint32x4

.

1i

sint32

.

2i

sint32x2

.

3i

sint32x3

.

4i

sint32x4

.

2u1

uint8x2

.

4u1

uint8x4

.

2i1

sint8x2

.

4i1

sint8x4

.

2h

float16x2

.

4h

float16x4

.

2nu1

unorm8x2

.

4nu1

unorm8x4

.

2ni1

snorm8x2

.

4ni1

snorm8x4

.

2u2

uint16x2

.

4u2

uint16x4

.

2i2

sint16x2

.

4i2

sint16x4

.

2nu2

unorm16x2

.

4nu2

unorm16x4

.

2ni2

snorm16x2

.

4ni2

snorm16x4

.