Metashade: Compilerless Immediate-Mode Shader Generation in Pure Python
Metashade addresses the challenges of shader programming, specifically portability across different rendering APIs, the permutation explosion in real-time shading, and the lack of high-level abstractions and modularity in C-like shading languages. While existing solutions like Warp or Taichi use introspection to capture Python's Abstract Syntax Tree (AST) and compile it via C++ backends, Metashade avoids the compiler approach entirely.
The system utilizes a tracing mechanism and immediate-mode code generation. Rather than parsing the AST, Metashade emits target code eagerly as Python code executes. This allows for the interleaving of arbitrary Python logic with shader generation, enabling powerful metaprogramming. A central polymorphic generator object manages the semantic model of the shader, tracking scopes and local variables to ensure semantic correctness without relying on simple string concatenation.
To emulate C-like semantics within Python, Metashade employs specific architectural patterns. It captures symbols by treating meta-variables as members of the generator, which enforces static typing and value-based assignment. Operator overloading is used to implement an expression builder pattern, allowing the system to enforce stricter type safety than the target language—such as prohibiting the addition of a color and a point. C-like scopes are emulated using Python context managers.
Metashade supports multiple targets, including HLSL and GLSL, and integrates with the MaterialX standard for physically based rendering (PBR). By moving design-time decisions to Python, it replaces complex C preprocessor macros with readable, maintainable Python code. This approach improves debuggability, as semantic errors trigger Python exceptions that can be analyzed with standard Python debuggers.
This description was generated by Open-Source AI using the transcript of the session and the original submission contents.
This session took place in track PyData & Scientific Libraries Stack and was classified suitable for novice domain / intermediate python by the speaker.
Submission
The proposal as submitted by the speaker before the conference.
The area of shader programming offers many tough problems to solve. The range of target platforms is vast: from CPU path-tracers to mobile GPUs - served by a zoo of incompatible languages: from GLSL to HLSL, from OSL to WGSL.
Common challenges include portability, managing specializations, and a lack of abstraction mechanisms. The solutions for these include the archaic C Preprocessor, templates/generics, visual graph frameworks, transpilers and, finally, embedded domain-specific languages (EDSLs).
Python is an ideal host for Embedded Domain-Specific Languages (EDSLs). Warp, Taichi, Numba, and Triton evolved to target GPU compute. All of them share common architectural decisions. They capture the program's logic by inspecting the Python source code, generate an internal representation and compile that IR to the target format.
The above approach comes with significant disadvantages. Only a subset of Python is supported, debugging with standard tools is impossible, integration with external Python code is limited, metaprogramming requires special syntax, and heavy compiler infrastructure needs to be implemented in a language like C++.
This talk proposes an alternative architecture. Instead of introspection, we capture the program's logic by tracing execution with proxy objects at Python runtime, similar to JAX and PyTorch. Instead of building an IR, we emit target code eagerly, line-by-line, similar to how PyTorch Eager Mode launches computations. And because we don't implement a compiler, the implementation remains 100% Python.
We discuss in detail how core elements of Python syntax can be overloaded to implement such an architecture:
- Operator overloading to capture expressions.
- Context managers to simulate C-like scopes.
__setattr__/__getattr__to capture variable names.- Function decorators to capture function signatures.
Attendees will leave with a toolbox of Python mataprogramming patterns empowering them to write a code generator in Python without having to implement a compiler.
Transcript (auto)
Auto-generated from the recording utilizing Open-Source AI. Speaker labels (Speaker 1, Speaker 2) reflect diarization, not identity. Timestamps refer to the recording.
Speaker 1 [00:06]
Hello, welcome everybody. I hope you all are having a wonderful conference so far. So before I give an intro to the topic and the speaker, again a short reminder that please post your questions on talks.pycon.de and don't ask your questions during the talk. that being said let's introduce to the topic so the topic for today is metashard compilerless immediate mode sharder generation in pure python and this talk will be given by our speaker who is paulo penenco a short intro about him he is a principal software developer at autodesk working on material and sharding workflows in MaterialX and Hydra applications. So without any further ado, let's get started and let's applaud our speaker with a big round of applause.
Speaker 2 [01:16]
So let me rephrase maybe the title a little bit. So I'm going to speak about Python hacks that make it possible to write a shader generator without writing a compiler. And I'm going to use my open source project Metashade as an example of implementation. But first, the fun part, the disclaimer. I work at Autodesk, and I'm obliged to say that I'm here in my personal capacity. and the views expressed are my own and not those of Autodesk and the information presented here does not necessarily represent the views of Autodesk or its partners. And now on a slightly less professional note, one of my favorite memes. What's a shader? So basically there are two intuitive definitions of a shader. On the one hand, it's a program that's used in a rendering pipeline to eventually compute the value of a pixel, either at real time or in offline rendering. On the other hand, a shader is something that's executed on a GPU. So the top left cell is probably the most famous example of a shader. So a fragment shader or a pixel shader that executes on a GPU in a video game. But then, OpenCL, or CUDA for that matter, they're also, well, executed on the same hardware, and while the difference, the only difference here is that they're typically not used for rendering. So in some way, they're shaders, and there's definitely some overlap here, and we can reuse some techniques between shaders and kernels. And on the other hand, there's GIMP there, But in reality, well, a good example would be CPU shaders written for production path tracers like Arnold or RenderMan. They've traditionally been run on CPUs, but these days they're ported to GPUs as well. And, well, before we... So why would anyone want to write shaders in Python? And aren't there good enough tools for that? Is the area of shader programming hard? So I would claim that these are some major challenges that are present there, and they don't actually have very good solutions to this day. So challenge number one is portability. This is pretty self-explanatory. well if a game studio develops a game which needs to run on a console on pc on on on a mac on a mobile device there are differences between the rendering apis and the shading languages that are supported on on those apis but going beyond that there there are sometimes portability requirements between real-time shading and offline shading so for example So virtual production is becoming popular in visual effects pipelines. For example, The Mandalorian was filmed with huge LED screens behind characters so that they cast realistic lighting onto the characters. And those LED panels were driven by Unreal Engine. So you ideally should have a shader that looks similar in a game engine and a production ray tracer used for the same show. And lastly, it's not only the rendering pipelines, the graphics platforms or sharing languages that are important. The applications themselves require certain API for developing shaders for them. So, for example, Maya expects one API and Unreal Engine expects another. And ideally, you should be able to accommodate these existing APIs. Then another big one, and this one is unique to real-time shading, is the permutation explosion. Some of you may recognize this infamous UI element from Unreal Engine, compiling shaders. You often need to wait for many minutes or maybe even hours when the level compiles. So what is happening there? Do shader writers really write thousands of shaders? Of course not. So they do write, they do create some materials, either by coding or in the visual editor. But then the engine has to create a lot of permutations of those shaders, because in Real-time shading, those permutations depend on a number of conditions. What kind of material is mapped onto a surface? What kind of geometry is there? What kind of vertex inputs it provides? And what kind of lights apply to the surface? And it's important to note that this is fundamentally a design time problem. So these are not run-time conditions. These are not if statements in the shader. these conditions, they're evaluated as part of the shader pipeline. And all of these permutations, they need to be baked in separate pieces, in separate shaders. Next up, low level of abstraction. So a majority of the shading languages are very C-like, and they don't actually introduce much on top of C. So in the CPU programming world, a lot has changed since C was a thing, but not so much in shaders. So, for example, the lack of generic programming is very notable, taking into account this permutation explosion problem. And finally modularity and code reuse. Certain languages and ecosystems lack the most basic features like linking. It pretty much is not non-existent for GLSL or HLSL. Even includes are not part of the GLSL specification. And there is no, as I like to determine, there is no STL for shaders and STL is the standard template library for C++. As a C++ developer, I remember the days before the STL became widespread, it was pretty much impossible to write generic algorithms in C++. And I would claim that this is the status quo in the world of shader programming these days. So how do I know that these are the pain points and that the struggle is real? So I worked with shaders from different angles throughout a couple of decades. I worked for about a decade in game development. I worked on some VR projects at AMD, on an animation pipeline at ToonBox. And, well, most recently I've been working in the DCC area. So DCC stands for Digital Content Creation. It's applications like Maya, Max, and so on and so forth. Right now, I work at Autodesk, working on shared visualization tech, shared between our products, and that involves a lot of open source too, such as MaterialX and Hydra slash OpenUSD. And I have to confess that I'm mostly a C++ programmer by day, but Python is really essential in the visual effects industry that we serve. so what are the existing solutions to those pain points so first of all the C preprocessor and if you're familiar with C I guess you would agree that this is kind of an evil sometimes a necessary evil but in the shader programming world it's even more pervasive than in CPU programming, exactly because we need to generate those permutations. And so we're left with technology from the 70s, basically. Yeah, generics and templates are a good replacement for that, but while they have some downsides as well, like C++ templates are also kind of infamous, and some of the platforms, like GLSL, doesn't have any generic programming mechanisms. Then micro shader frameworks, a really popular approach and a very efficient one for sure. The idea is to author small fragments of shader code and then to combine them somehow. And the most productive way of combining them is some kind of graph which can be edited in a visual editor. So Unreal has a mechanism like that. Maya as well and Material X is the new open source standard. for that. And transpiling is the go-to solution for portability these days. So Spurvy Cross, Slang, Tint, the idea is you write your shader once in one shading language, and then it gets transpiled to a different one. But then there's the idea of EDSLs, Embedded Domain Specific Languages. So the idea is that you take a general-purpose programming language like Python or C++, and you embed your little EDSL into it. The closest examples to shading EDSLs in Python are these four, Warp, Taichi, Numba, and Triton. Because they target the GPU at a much more lower level than PyTorch or even JAX. And they all share some common architectural decisions. So basically you decorate your Python function, and then the framework uses introspection to capture the Python's AST and compile it to something else. And the compilation is actually done with something, it's usually implemented in C++, so it's not a pure Python solution. And one downside of this is that this approach supports only a subset of Python that maps onto the target platform, so you're essentially compiling the, well, C-like language with Pythonic syntax. Now finally we get to Metashade. Metashade is specifically designed to program shaders, well, that's the GitHub link, it's the license is Apache 2.0. And it does take a different approach to shader generation than those for, like, Warp and Taichi. So, well, here we have some Python code, and it generates this HLSL code. And next I'm going to go into detail about how that actually is implemented. But first of all, some demo, just to prove that it works. It's a very meat on potatoes kind of rendering, some rasterization. Basically I'm taking a host application developed by AMD and I replaced uber shaders implemented with the C preprocessor with shaders generated by Metashade. So how is Metashade different from Warp or Taichi? So first of all, we don't rely on introspection. We rely on tracing. Kind of like JAX or PyTorch. We don't parse the Python AST. And, yeah, we don't write a compiler. We just emit code when executing Python code in a Python interpreter. And the second principle is immediate code generation, immediate mode code generation, kind of similar to how PyTorch eager mode launches computations immediately, eagerly. We generate code eagerly, line by line, without building some kind of intermediate representation. And this provides some nice benefits. It becomes possible to interleave this metacode with arbitrary Python code. And also this enables metaprogramming. Because we can build abstractions with Python around this basic code implementation. And well, yeah, just a diagram to illustrate this. Then I need to talk about generators. This is a central concept in metashade. So it's basically a polymorphic object that everything goes through in the cogen process. It's named SH by convention, but, well, it's just a convention. So basically you always create a concrete generator for specific targets, for example for HLSL of a specific version or GLSL of a specific version. But then you can write a generic algorithm that works with this polymorphic generator and doesn't need to know what the actual implementation is. What's inside the generator is basically a semantic model of the shader being generated. So it keeps track of the stack of scopes, and each scope has a dictionary of locals and so on and so forth. So it goes to show that even though we're not writing a compiler, it doesn't mean that we're just concatenating strings. So we actually do verify the semantic correctness of what we're generating. The next pattern is perhaps the most important one, and maybe the strangest one as well, capturing symbols. So how do you capture symbols if you don't use introspection? And also a related question, how do you overload assignment to have C-like semantics? Because well, in C, assignment is by value. In Python, it's always by reference. So the hack to make it happen is to pretend like these metavariables are members of the generator. So it's always sh.something. And yes, it does produce kind of idiosyncratic syntax, but this is a tradeoff that we're making to enable metaprogramming capabilities. So this allows us to enforce C-like behavior that would be otherwise impossible to implement in Python. Things like lifetime management and static typing. So in Python, you can just point X to a different object, right? But in C, you cannot change the type of a variable after it's been initialised. And a very nice consequence of this is that regular Python variables and these meta variables live in different namespaces, so this really enables you to do anything you want in Python on top of your code gen. Okay, operator overloading is kind of self-explanatory. Instead of performing the mathematical operation right there and then, we build an expression. So it's the expression builder pattern. And here we have another great place to build a code. great spot to check our semantics. We can enforce stricter semantics than the target language allows. So, for example, we can prohibit the addition of a color and a point, even though they're represented by the same type in the target language. So, representing data types, it's kind of continuing on that theme of polymorphic tracer objects. So, unlike some other solutions we don't map Python types to target types. We instead have these polymorphic tracers which encapsulate their semantics in their implementation. Some more examples of what the source and the generated code look like. Then another big one is emulating C-like scopes, and I kind of covered it already, so basically because while the shader knows when the scope is closing, we can track that a variable goes out of scope, and to implement implemented in our EDSL, we're using context managers, the with statement. Just to make it more obvious, the difference between design time and runtime code, which you can mix freely, So the Python control flow still exists, but it's responsible for design time decisions, and those are the ones that are so important in real-time shading. Function definition syntax, one pattern is using context managers again, and the other one is using a decorator. Here we have a metaprogramming example, which is admittedly a bit too involved. But, yeah, well, it's just something that you would typically implement with a C preprocessor otherwise. And in this case, we are using the PyGLTF lib to parse GLTF assets, GLTF materials, and we generate code based on that source asset. So it gives us an example of how you can integrate with arbitrary Python code. And then there is now a prototype integration of Metashade with MaterialX. MaterialX is becoming the standard for material authoring and exchange in the visual effects world. And it's an example of a micro-shader framework. It has a visual editor. And this here is an actual shader graph for the open PBR surface node, which is the latest and greatest standard, PBR stands for physically based rendering, the latest and greatest physically based rendering surface model. So as you can see, it's completely unmanageable in a visual editor, and it just demonstrates that visual programming is not always the silver bullet, and code is still better at expressing complexity. And then, why it was easy to implement a metashade integration, because like many visual effects pieces of software. MaterialX, even though it's implemented in C++, it has a MaterialX API, so it's easy to get the metadata for specific MaterialX nodes, so you can call MaterialX nodes from Metashade and vice versa. And this concludes my presentation. Here are some links. Please check them out. Thank you for listening. Thank you.
Speaker 1 [23:06]
Thank you, Paolo, for the talk. So Q&A time. First question, so a rather straightforward question. What is introspection?
Speaker 2 [23:19]
So, yeah, introspection is basically when a program can treat itself as data. So, in Python, you can use the inspect module, inspect package, to inspect the structure of the Python program itself. So you can analyze the control flow, the types of function parameters with type annotations, and so on and so forth.
Speaker 1 [23:54]
Alright, thanks. Next question. Why not write your shaders in Rust? Is the cost of switching too high?
Speaker 2 [24:05]
Well, there are definitely projects around that. There is Rust GPU, I think it's called, which is what it sounds, basically. But the difference here is that that's still a compiler-based approach. So it doesn't really offer this kind of metaprogramming. Actually, the closest thing that I would compare Metashape to would be Circle. I don't know if anyone is familiar with it. It's this C++ extension developed by one person, Sean Baxter, which offers superior metaprogramming to C++, so imperative metaprogramming, for example. So I would compare Metashade to that approach.
Speaker 1 [24:51]
Okay, thanks. Moving on, next question. So first of all, acknowledgement for the great project.
Speaker 2 [24:58]
I
Speaker 1 [24:59]
Are you planning to support exporting to Godot shaders at some point?
Speaker 2 [25:06]
Absolutely, but it's a question of bandwidth, because my time is limited, so I'm definitely more familiar with shaders, and right now, MaterialX is really my priority, because it already has these multiple backends, and it already integrates into tons of host applications. But with CUDA, I would encourage anyone who's interested to contribute their own generator, For sure, it would be a great application.
Speaker 1 [25:40]
Alright, thanks. Next question. Any opinions on Mojo? It seems like it has similar goals, albeit more general. A shader could just be another compilation target.
Speaker 2 [25:56]
Yes, yeah, I definitely know about Moja, but to me it feels more like Warp or TaiChi. So it's an extension of Python where you compile with C-like semantics. So again, this is a compiler-based approach, and again, the metaprogramming tricks and metashade would not be possible there.
Speaker 1 [26:28]
All right, our final question of the talk, does Metashader improve the debuggability of shader code?
Speaker 2 [26:39]
Yes, I would say so. So on the one hand, the readability of the target code matters. Metashade generates target code that's very readable, unlike some other approaches. So for example, with transpilers, it's a common problem that you usually just lose comments, for example, and you use any generic programming that happened upstream, but also with Tint specifically, you get some not-so-readable code, and that complicates debugging on the back end. But second of all, those examples where some semantic check is enforced in Metashade, it basically just throws an exception. So you can use your favorite Python debugger to just see the whole stack and see in Python what kind of semantic check failed. If it was something based on a compiler, you would get a compilation error, and then, well, you wouldn't debug a compiler written in C++. So I would say that debuggability is even better in the area of metaprogramming.
Speaker 1 [27:57]
All right. Thank you, audience, for your wonderful questions and being a good audience. And let's end this session by applauding our speaker, Paolo.