Why should I prefer zengl over moderngl? · szabolcsdombi zengl · Discussion #37

5 min read Original article ↗

I hope this helps.

ModernGL

ModernGL was first released around 2016. However it gained not much traction until version 4.x or 5.x and those versions are very different from the previous ones.
The most recent version of moderngl is mostly the same idea as it was in 4.0.0 (2017-05-20).
Since then new features were requested and added. Some of them duplicated existing functionality.

An average ModernGL users does not use these new features.
Instead, they mostly rely on the very basic set of features that were already present in 4.x.
Take textures for example:

program = ctx.program(...)
texture = ctx.texture(...)
model = ctx.simple_vertex_array(...)

program.use()
texture.use()
model.render()

Later samplers were introduced, and were possible to bind independently from textures.
So texture.use() should have been deprecated in favor of sampler objects.
Such a change would not have been welcome.

Same goes for the scope objects.
Scope objects assigned to vertex array objects make the render independent from the global state.
For code that is not written with scopes in my from the start commonly ended up with bugs like this.

vao.scope = ctx.scope(samplers=...)

ctx.enable(DEPTH_TEST)
vao.render()  # depth is not enabled here as the vao.scope has an empty enable only

Yet again the ctx.enable() was not deprecated in favor of the most common use cases.

ModernGL has a lot of features that are probably less reliable than worth using.
There are compute shaders in moderngl, shader subroutines, storage buffers, indirect draw.
This is because the OpenGL API on some systems are not well implemented.
Also there is OpenGL ES that has a bit more restrictions compared to desktop GL.

For ModernGL there were nothing I could do despite I tried it many times.
I consider ModernGL stable as it is. New features are still welcome. Deprecation of old features is unlikely.

ZenGL

I designed ZenGL from zero, based on the experience gained implementing ModernGL.
There were two options I could take. Make a library with the most stable subset of OpenGL or the most recent one.
As it turned out painfully, the most recent one is not reliable, despite OpenGL 4.6 is widely supported, some drivers still have bugs.
So ZenGL uses OpenGL 3.3, 100% compatible with OpenGL ES 3.0, uses the same subset as WebGL 2, hence it also runs in the browser.

Does this mean ModernGL has a better OpenGL supports?

Well, no. Here is why:

ZenGL has no compute shaders, but most of the compute shaders you plan to implement with ModernGL could have been implemented with a fragment shader.
On some old hardware this actually results in faster execution.

ZenGL has no storage buffers, read/write images in shaders, atomic counter, and so on.
Well, you can still use any OpenGL binding and have interoperability with ZenGL.
This already does not sounds like rendering. Did you consider vulkan for it?
I actually tried (twice) to make a vulkan library for such use case.

ZenGL has less vertex and image formats available, but here is the thing: ZenGL uses the same subset of vertex and image formats as WebGPU.
The other types are either slow or not well supported or just implemented with the provided formats anyways.

So using ZenGL you might find no float16x3 for size optimized normal vectors. Adding a bit of padding and using float16x4 is actually way faster.

For a beginner ModernGL might look easier to use and learn.
On the other hand ZenGL actually expects the user to know OpenGL well.

For a seasoned OpenGL developer, ZenGL provides a richer experience mostly due to the self-contained pipelines and internal caching.

The features ModernGL could not have landed in ZenGL.

Self Contained Pipelines

This was actually done in moderngl as scopes. But due to the global state it is not as realiable as I wanted them to be.
ZenGL Pipelines are the proper Scope objects.

Caching

The title is a bit of misleading. Caching here actually means the ability to know what is in use and not to rebind it.
To avoid unnecessary api calls or even worse pipeline flushes.

For example:

Having two ZenGL pipelines with the same global state, rendering the second pipeline does not set the global state.
Having two ZenGL pipelines with the same shader code, rendering the second one does not call glUseProgram().
Having two ZenGL pipelines with just the vertex count, vertex offset or instance count different, rendering the second pipeline adds just another glDraw call.
...

Caching is nothing fancy on the inside.
In hot code it is usually a bitwise check at C level.
It does not compare (in terms of speed) to anything that can be done on the Python side.

Immutable Objects

In ZenGL Pipelines are immutable.
This is an obvious helper in debugging. There is exactly one place to look for the problem.

Does the pipeline render to the wrong framebuffer? -- it was defined when the pipeline was created.
Does the pipeline render with the wrong texture? -- it was defined when the pipeline was created.
...

Error Checking

Self-Contained, Immutable pipelines allows us to actually check if the state is complete:

Having a uniform buffer without a binding is likely an error.
Having an unbound attribute without explicitly bound to nothing is an error.
Having something bound to an inexistent binding slot is an error.
Having duplicate bindings is an error.
...

I really wanted this in ModernGL but is clearly not possible without Self-Contained, Immutable pipelines.

Bottom Line

To pick between ModernGL and ZenGL is mostly the matter of taste.
For simple projects (I would say for most, or all of them) it does not matter much.

Implementing the rendering is mostly the effort of writing shaders, not just how you structure your code.

ZenGL has web support, this is a fairly new thing.
ZenGL works with pyodide and has no performance penalty compared to writing WebGL 2 code directly in JS.