Building imgplex: part 5

game-dev imgplex tooling

This is a series of posts on building imgplex, best read in order:
Part 1 - The why, what, and how of imgplex
Part 2 - Getting things up and running
Part 3 - The node definition system
Part 4 - Executing the node graph, making it fast
Part 5 - Two graphs in one
Part 6 - Multiple inputs and outputs, processing images as sets
Part 7 - The small, measured optimizations beneath the big ones

Two graphs in one

The previous post talked about the node graph execution. So far these posts have talked about the node graph as if it does one thing: push images through a chain of ImageMagick operations. But there’s a second graph living inside the same canvas, and it never touches an image at all.

If you’ve used Substance Designer or Blender’s geometry nodes, this will feel familiar. Some nodes carry the actual thing being processed - the image, the mesh, the texture. Other nodes just compute values: a number, a comparison, a bit of math whose result feeds into a parameter somewhere else. Both live in the same graph and connect with the same wires, but only one of them is doing the heavy lifting.

imgplex has exactly this split. There are image nodes - anything with an image or mask port, executed as ImageMagick operations - and pure-value nodes - math, logic, and constants that get evaluated in JavaScript and never spawn a single magick process. The thing that lets these two coexist cleanly on one canvas is that every port is typed.

Typed wires

Every port in the graph has a type, and every wire connecting two ports has to respect it. You can’t wire a color into a slider that expects a number - the canvas checks the connection as you make it and simply refuses the ones that don’t fit.

This is the same idea as a shader graph refusing to plug a float3 into a float input. Types kill off a whole category of mistakes before they can happen, and they make a dense graph readable at a glance. The validation covers a few cases: a port can’t connect to itself, a connection that would form a cycle is rejected, and the core types - image, number, boolean, string, path - only connect to compatible ports. There’s also an any type for nodes that are type-agnostic; once one wire lands on an any port, its siblings on that node are constrained to match, so a generic node can’t end up with a nonsensical mix of input types.

To make all of this legible, each type has its own wire color:

When you drag a wire out of a port, the preview wire is already tinted with the source type’s color, so before you’ve connected anything you can see what kind of value is flowing. In a busy graph this ends up being the fastest way to trace what connects to what - you just follow the color. The palette isn’t arbitrary either; each color was checked for AAA contrast against the dark node background, to ensure easy legibility.

The pure-value graph

Pure-value nodes are the ones with no image ports at all - their input and output lists are empty. Instead of pixels they deal in numbers, strings, booleans, and vectors. They come in a few families:

That last family is the bridge between the two graphs. A Properties node doesn’t process the image - it reads a fact about it and hands that fact to the value graph as a plain number or string.

How the two graphs connect

The connection point is parameters. Every editable parameter on an image node - the sigma on a Blur, the angle on a Rotate - exposes an input handle on its left side. That handle accepts a wire from any compatible value output.

So you can do things like: read an image’s width with a Dimensions node, halve it with a Math node, and wire the result straight into a Crop node’s parameter. The crop now adapts to each image’s size on its own, with no fixed numbers anywhere in the graph.

When the pipeline runs, it evaluates the whole value graph first, in dependency order, so that by the time an image node executes, every one of its parameters is already a concrete resolved value. The value graph is essentially a precomputation pass that front-loads all the arithmetic before any ImageMagick work begins - which is exactly why the image side of the engine can stay so simple. Every image node just needs its parameters handed to it as finished values; it never has to know they came from a chain of math nodes.

Three ways a node computes

Early on, every node was just a command_template - a fixed ImageMagick argument string with {{placeholder}} slots filled in from parameter values. That covers a surprising number of nodes: a Blur is -blur 0x{{sigma}} and nothing more.

But plain substitution hits a wall fast. What if a flag should only appear when a checkbox is on? What if two parameters need combining with a bit of math first? What if the node computes a value and never touches an image at all? So the model grew into three ways to define what a node does:

All three are still just fields in a JSON file. Adding a node with conditional logic doesn’t mean touching the app’s source - you write the snippet inside the node definition, drop the file in, and it hot-reloads like any other node.

It’s worth being honest about what those snippets are, though. command_js and compute_js are real JavaScript, and they run in the app’s main process - the privileged side that can touch the filesystem and spawn processes. That power is the point, but it’s also a responsibility. Keeping executable logic inside node definitions is fine, because those are part of the app or files you’ve deliberately added. Workflow files are a different matter: they’re built to be shared and double-click-opened, which makes their contents untrusted input. Drawing a hard line so that executable code can only ever come from a trusted node definition, and never be smuggled in through a shared workflow, turned out to be worth a whole post of its own - that one’s coming later in the series.

The handful of genuinely complex nodes - the ones that split an image into four channel outputs, or resize with a mode switch - still use hardcoded executors in the app’s TypeScript. Multiple output ports and variable port shapes are the two things JSON alone can’t express. But a lot of behavior I initially assumed would need real code turned out to be expressible as a JSON node with a small command_js snippet.

Data-driven niceties

Two smaller things fall out of this typed, data-driven approach, and both make the node library feel less mechanical.

Where this leaves the architecture

The two-graph model - a typed value graph feeding parameters into an image processing graph - is the part of the design I’m happiest with. It keeps the image pipeline dead simple, because every image node only ever sees finished parameter values, while making the graph itself genuinely programmable. Complex adaptive behavior emerges from wiring simple typed nodes together, rather than from any one node being clever.

It also happens to be why the workflow builder runs in a browser with no ImageMagick at all. The value graph is pure JavaScript, so you can build and wire an entire workflow in the web version and only need the real backend when it’s time to actually process pixels.

Next post in this series: Building imgplex: part 6 - Multiple inputs and outputs, processing images as sets