This is meant for developers that either want to contribute to this project, or just want to understand the underlying structure behind it.
This is a project written in Microsoft Visual Studio 2026 that uses a CMake build system with ninja (or jinja), using C++20. It purposefully avoids using MSVC-specific features for portability: had this project used C++ Modules, this wouldn't be portable to other systems, namely linux (since GCC and Clang haven't finished implementing them yet). Since all this project does is graphics, we delegate most of the math to our GPU. This is done through the OpenGL framework, the shader code being written in GLSL. This particular stack was chosen because C++ is a highly performant language, and there is many online resources and guides for using it along OpenGL.
All the source code is stored in two main directories:
src/, which stores the source code written for, and specific to, this projectdependencies/, which are all external libraries/reused code from old projects. Namely, thelearnopenglsubdirectory contains custom classes (likeTexture), that are not directly taken from an outside source, and rather developed by myself. The subdirectories ofsrcrefer to specific modularized features of the plotter, which will be discussed in this document.
There are three main types defined for parsing and handling functions and operators (functions are treated as operators internally, so sin(z) and sin z are the same): Operator, TokenOperator and FullOperator.
Operatoris an enum used to identify operators, it contains no other informationTokenOperatoris a struct used for most parsing purposes. It contains the operators's arity (anArityenum,NULLARYfor constants), its associativity (anAssociativityenum), its string representation, whether it's higher order or not, precedence and value, if a constant. It contains all informations needed to parse and constant fold most expressions.FullOperatoris a struct all the information needed to prepare shaders: aTokenOperator, its GLSL#definedirectory name and its GLSL corresponding function or string (Operator::ADDcorresponds tocadd(vec2 z)andSHADER_ADD)
There are two main ways we render things: through Interpreted Shaders and Compiled Shaders.
Interpreted shaders have a list of pre-defined opcodes that are source-injected into it at compile-time. Then, a Texture Buffer Object containing the queue of operations to be done (in reverse polish notation) is sent at runtime, and evaluated by the interpreter.
Two different stacks are sent to interpreted shaders: a vec2 queue of constant values, and an unsigned char queue of opcodes (internally represented as uint). Whenever the interpreter comes across the CONSTANT opcode, it pops the top of constant queue, and appends it to our evaluation stack.
For example, assume we want to represent the expression (z+3)*2. Or, in RPN, 2 z 3 + *. This would be sent as following:
Operator queue: CONSTANT, VARIABLEZ, CONSTANT, ADD, MULT
Constant stack: vec2(2.0f,0.0f),vec2(3.0f,0.0f)
We use vec2s because our constants are complex-valued: they have real and imaginary components. Whenever the interpreter comes across a CONSTANT symbol, it grabs the front of the queue, and pops it.
The advntage of interpreted shaders is that there is instant feedback: binding a TBO is a very cheap operator, meaning it can change seamlessly. The problem is that doing stack operations at runtime is expensive, making it slower for large expressions
Compiled shaders inline all the logic that interpreted shaders do. If you want to render the expression e^z * z, rather than sending a stack to be evaluated at runtime, it will source inject the string cmult(exp(z),z) into the function's value and recompile the shader.
This is far faster than interpreting the function, but recompiling takes time. So, it doesn't have the same "instant" change as the interpreted shaders do. That's why a shader is by default interpreted, and if the user wants more performance, they can compile them.
There are 4 main steps that this program works in, with the following sequence:
Pre-processing -> Parsing -> Preparing shaders -> Rendering
This step is used for two main purposes:
- Shader opcode synchronization
- Source injection
Shader opcode synchronization is simply making sure that the opcodes we send to the interpreted shaders match the ones it internally stores. After all, if we send the expression
CONSTANT CONSTANT ADDas2u,2u, 6u, and, internally,6uis the code forMULT, we are getting a wrong plot. This is prevented here, as the source code for all interpreter logic are written at runtime. Source injection is also done at this step: all the base code is written to our 2D interpreted shader (plotter.frag), and all the shaders that require functions shared withplotter.fragcopy these 'blocks' from it. After all, we might have issues where the same function being interpreted and compiled looks different, which is not something we can have.
We tell the preprocessor where these "blocks" are with the following syntax:
#define BLOCK_NAME HERE
[block content...];
#define END_BLOCK_NAME HEREThe name "parsing" might be misleading, because we do far more things in this step than just processing user input. This step involves:
- Processing user input
- Evaluate higher-order functions
- Simplifying the result
We process user input by first breaking the input string into TokenOperators. This is done by verifying whether the current character matches an operator name, or, if not, is a string with a name matching an operator's (this uses
TokenOperator::str_repr). Once we converted the user's input toTokenOperators, we verify if there is implicit multiplication (2zis the same as2*z) and break apart ambiguous operations (-zis negation, not subtraction), and modify accordingly. Then, we run the shunting-yard algorithm in the resulting list ofTokenOperatorto get a reverse polish notation queue.
Then, we transform our RPN queue into an Abstract Syntax Tree to evaluate higher-order functions. In particular, the derivative has hard-coded rules for binary operators (product rule, quotient rule, etc) and uses a lookup table for unary operators and functions, applying the chain rule to it.
Namely, we use the build_expression function to create a subtree through text, with its variables set to uplaceholder (so, for instance, if sin(z)' = cos(z), in our lookup table, we'd have table[Operator::SIN] = build_expression("cos(uplaceholder)")). Finally, we replace uplaceholder with its derivative, recursively applying the chain rule.
The resulting syntax tree is then converted back into an RPN stack, and sent to the next steps.
Then, we have to simplify our final results. Simplifying does the following:
- Transform broken up pairs
a + b*iinto a single complex scalar,(a,bi) - Constant folds known expressions
(2 + 3 * 5) z->17z
This is the step in which we pass uniforms to the shader, and all UI logic is handled. Notably, there's two UI states we are keeping track of:
- FunctionState
- ViewState Function State stores all relevant information related to the rendering of complex functions: whether it's interpreted or compiled, if it's 3d, if we should reparse it, between others. Essentially, all the UI buttons you see and interact with, alter function state. View State contains information regarding user interactions. Namely, it is what event callbacks see whenever you zoom or pan across the function's plot. This is also in which the program decides which shader to render and show to the screen.
Here's where all our math logic is ran. We have a block in plotter.frag that contains all complex function definitions we need, which is source-injected to any other shaders that might need to run these functions. Currently, the plotter supports every elementary function.
The grid is drawn either using z or f(z) as a parameter, which is what the "distort grid" parameter does.
The domain_coloring function is what maps f(z) to a color. These are the parameters used:
Where
3D mode is rendered by making an NxN grid mesh, handled by the Mesh struct and create_grid_mesh function, and shaping it around the function's "real" shape (picking points in set spaces and placing vertices there).
Height is directly mapped to |f(z)|, so information is redundant. Movement is done through a custom Camera struct.
Whenever you hover over a value and get a number back, this is done through a "picker" shader. It's a 1x1 grid rendered off-screen that calculates the value of f(z) precisely where you are hovering. The result is then shared as an out vec4, the first two floats being z, and second two, f(z).