optimize-hydra
Optimizing hydra's GLSL shader generator
Introduction
Recently, I have become obsessed with hydra, a web-based library for live coding graphics. Using hydra, a live-coder can create fun audio-reactive visuals in an intuitive and fun way. Here is a simple example:
10,0 1,0 2
0 1, 0 2, 0 4
, 2
o0
0 1
0,0 1
This chain of functions produces a transform tree, which hydra uses to generate and run a GLSL shader.
Hydra is super easy to use once you understand the general principles, but being me I got really curious about how it worked. As I understood hydra better, I started poking around the source code in order to extend hydra, and found an opportunity to optimize the output GLSL code.
Here is an explanation of how hydra works, and the changes I have proposed.
How Hydra Works
First, lets look at some basic examples.
The simplest hydra I can think of is:
10
This hydra generates the following glsl
code, which draws a sine wave across
the screen:
vec4
void
In the main
function, we can see a pretty straightforward relationship
between our hydra code and the generated GLSL. But where does the osc
function
definition actually come from, and where do those extra numbers come from?
Hydra has a file called src/glsl/glsl-functions.js
which contains an array of
objects; each object contains the actual source of the GLSL function, as well as
some metadata that hydra needs in order to create the shader:
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
The first thing that jumps out to me is the glsl
entry, which contains the
body of the osc
function in our GLSL code, and the inputs
array which
seems to match some of the GLSL function arguments.
If we look at the output from the shader again:
gl_FragColor = ;
We see the extra numbers are the default
values from the inputs
array!
What we don't see, however, is the return type in the function definition,
anything related to the st
/_st
parameter, or any immediate explanation for
the type: 'src'
key. Helpfully, a comment at the top of the file explains
that the type
corresponds to an entry here:
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
Hydra defines a few built-in categories of functions with required arguments. We
also see that the functions only return vec4
or vec2
. Let's take a look at a
more complicated hydra to get some more insight:
10
We added the rotate
function which is of type coord
.
void
We see that hydra inserts rotate
function call into the _st
argument of
osc
, thus changing the coordinates that go into the osc
by rotating them
before calculating sine wave gets calculated.
If we add a call to a color
type function called thresh
:
10
We can see that hydra nests the osc(rotate)
expression into the _c0
argument
of thresh
.
gl_FragColor = ;
Let's look at a more complicated example:
10
0 4,0 4,0 1
Analyzing the GLSL with all the numbers removed, so we can see the overall structure:
gl_FragColor = st;
// ^^^
We see that starting with the 'source' osc
function, the color
functions
appear from right
to left
, and the coord
functions appear from left
to
right
in order to have the correct ordering.
In other words, coord
functions nest deeper inside the expression, and
color
functions wrap the previous parts of the expression.
Let's look at one final example for now, using a combine
function:
1012,0 2
combine
and combineCoord
type functions are interesting: they allow us to
provide another src
chain as an argument, creating a tree structure:
osc(10) osc(12,0.2)
\ /
diff(|)
|
rotate()
|
thresh()
We can see this tree structure more clearly in the shader that hydra generated.
gl_FragColor = ;
However, we notice something interesting! Even though rotate
only appears
once in the hydra, it shows up twice in the generated GLSL shader in the
_st
arg position for both calls to osc
! This is because in order for the
rotate
function to affect all nodes in the tree above it, it must nest into
every branch of the expression.
The more complicated the tree above the rotate
call, the more times it
gets repeated.
Let's dig into they source code to figure out why.
Generating GLSL
The function generateGlsl
in src/generate-glsl.js
takes the hydra tree
structure and transforms it into the source string. Let's walk through how it works:
29 30 31 32 33 34 35 36 37 38 39
transforms
shaderParams
fragColor
generateGlsl
returns a function! This is an important detail.If we console.log
the transform
inside the loop, we can see that it has the
name of the function and a copy of the data we see in glsl-functions.js
:
If we look at the body of generateGlsl
s main loop, it has 3 main parts:
- Pre-processing inputs
- Adding the transform to a list of functions to lazily generate its glsl function body
- Assembling the expression string for
gl_FragColor
value
If we skip to the 3rd part, we see that the generator branches on the transform type and does some string template magic:
44 // current function for generating frag color shader code
45
46 if transform.transform.type === 'src' 47 48 else if transform.transform.type === 'coord' 49 50 else if transform.transform.type === 'color' 51 52 else if transform.transform.type === 'combine' 53 54 55 56 57 58 else if transform.transform.type === 'combineCoord' 59 60 61 62 63 64
The first line here saves off the previous loop iteration's version of
fragColor
as a new variable f0
. This allows for the generator to use
function composition to build up the structure of the shader expression.
Let's look at each branch 1-by-1:
46 if transform.transform.type === 'src' 47 48
shaderString
creates the actual glsl
function call; considering the osc
example, this will generate the string osc(st, 10, 0.1, 0)
.
If we look at the template string, we notice no reference to the f0
var, which
stores the previous iteration's fragColor
function; this makes sense, as src
functions are always leaf nodes of the tree structure.
An important detail to notice is that the first arg to shaderString
(which
is also the first argument to the function call) is special: rather than coming
from the inputs
, generateGlsl
provides it directly. The number and type of
these special inputs comes from the typeLookup
we saw earlier which defines
some special input types.
For src
functions, we expect a string containing the value of _st
:
'src':,
fragColor
is a function which takes the uv
as input; since _st
is the UV
coordinates, we pass the input uv
.
Transforming color
type nodes of the tree is a little more difficult as
we must involve f0
in order to compose the fragColor
with higher nodes in
the tree:
50 } else if transform.transform.type === 'color' 51 52
We see that the shaderString
gets f0
the result of passed in as its
parameter; if we consult the typeLookup
again:
83 'color': 84 85 86 ,
We see that color
functions expect a single argument _c0
which represents
the color value of the hydra that results from all the nodes higher in the tree.
The generator accomplishes this by nesting f0
inside the first argument of the
shaderString
call.
If we look at coord
functions next, we see something similar but different:
46 } else if transform.transform.type === 'coord' 47 48
We see that shaderString
generates the function call to our coord
function
in the inside of the call to f0
to change the uv
seen by all previous
iterations.
Next, let's consider the combine
function type:
52 } else if transform.transform.type === 'combine' 53 54 55 56 57 58
If we look at the typeLookup
definition, the input
field actually defines 2
input variables:
87 'combine': 88 89 90 ,
So the shaderString
expects 2 different color variables in its special first
input; this makes sense, because combine
nodes represent branches in the
transform tree.
_c0
comes from the previous nodes in the f0
branch of the tree. To generate
the _c1
branch, generateGlsl
calls itself recursively on input[0]
to
generate the _c1
branch, which it stores in the function f1
; it then passes
both expressions as a string through the first argument of shaderString
.
Finally, if we look at combineCoord
, we see a similar pattern:
58 } else if transform.transform.type === 'combineCoord' 59 60 61 62 63 64
Again looking at the typeLookup
data:
91 'combineCoord': 92 93 94
We see 2 arguments instead of 1 again: _st
expects the uv
coordinates, and
_c0
represents a branch in the tree.
To generate the branch in the tree, again generateGlsl
calls itself on
input[0]
which becomes the f1
generator, and we pass the result of f1
in
the _c0
argument position for the combineCoord
function. And then, just like
with coord
functions, we pass the shaderString
into the uv
for f0
, so
the effect propagates up the tree.
Observation 1: UV propagation
Now that we know how the generator works, let's walk through a more complicated example:
40,0 1,1
o04,0 5,14,4
The generator sees the following tree structure and traverses in the numerical
order I have written, with .
representing recursive calls:
1: osc() 3.2.1: shape()-3.2.2: repeat()
| /
2: saturate() 3.1: src(o0)-3.2: mask(|)
\ /
3: diff(/) 4.1: voronoi()
/ /
4: modulateScale(|)
|
out()
It produces the following GLSL output (with numbers truncated):
gl_FragColor = ;
We see modulateScale(st, voronoi(st))
repeated in every branch of the tree
above node number 4. This could potentially have a performance impact if your
graphics driver cannot optimize the repeated calls.
The more coord
or combineCoord
functions we stack later in the hydra, and
the more branches we introduce higher in the tree, the more repetitions we will
see!
If we look back at generateGlsl
we can make an interesting observation:
52 } else if transform.transform.type === 'combine' 53 54 55 56 57 58 else if transform.transform.type === 'combineCoord' 59 60 61 62 63 64
If we look at the fragColor
expressions specifically:
// combine
fragColor =``
// combineCoord
fragColor =``
The expression ${uv}
shows up twice in each; this copy is exactly the source
of the duplicated function calls.
Observation 2: Inputs vs Outputs
Let's look inside shaderString
:
73 // assembles a shader string containing the arguments and the function name,
74 // i.e. 'osc(uv, frequency)'
75 76 77 78 79 80 81 82 83 84 85 86 87
shaderString
formats the actual function call, performing any necessary
special handling of the inputs: namely, it checks if the input is a uniform and
places the name of its uniform variable in the argument position.
However, we also have a call to generateGlsl
! If we compare to the structure
of previous calls to generateGlsl
, we see that in actually they are not all
that different; comparing to combine
:
52 } else if transform.transform.type === 'combine' 53 54 55 56 57 58
The branch we see here using the ternary (? :
) is effectively identical to
how shaderString
already processes every input:
75 if input.isUniform 76 77 else if input.value && input.value.transforms 78 79 80 81
82 return input.value
generateGlsl
uses the first argument of shaderString
to side-load the
inputs it has already processed, and takes care to input.slice(n)
to skip
the inputs it has already processed.
The only real difference is that generateGlsl
propagates the uv
string,
where shaderString
starts with 'st'
. If we change it to use uv
:
75 if input.isUniform 76 77 else if input.value && input.value.transforms 78 79
80 return input.value
And in generateGlsl
, if we allow shaderString
to handle all input
variables:
44 // current function for generating frag color shader code
45
46 if transform.transform.type === 'src' 47 48 else if transform.transform.type === 'coord' 49 50 else if transform.transform.type === 'color' 51 52 else if transform.transform.type === 'combine' 53 54 else if transform.transform.type === 'combineCoord' 55 56
Several of the function categories actually collapse to the same structure:
coord
andcombineCoord
are exactly the samecolor
andcombine
are exactly the same
The only real difference between these function types is the position of f0
,
or how they compose with the rest of the tree!
Observation 3: Input Arguments
Comparing the typeLookup
map and the data in glsl-functions.js
, we see an
interesting bit of almost repeated structure:
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
75 'src': 76 77 78
We see that the function def contains the type and the name as fields of an object, but the type lookup keeps a string with the type and name together.
In our updated generateGlsl
function, we saw that our shaderString
function handles all inputs the same way. However, if we peek into
[src/generator-factory.js
], in processGlsl
we see that the typeLookup
args and function def args each require special processing.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
The first input always has special handling in generateGlsl
because the
first argument is where the generator expresses nesting behavior; however we see
that transform types which have multiple default inputs require those inputs be
specially added in this branch:
152 // add extra input to beginning for backward combatibility @todo update compiler so this is no longer necessary
153 ifobj.type === 'combine' || obj.type === 'combineCoord' 154 155
We can remove the need to do this by making the structure of input declarations
more uniform. If we change typeLookup
to the following form:
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
Then we can just directly concatenate the input lists in generateGlsl
,
removing a lot of special-case code:
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
With these simplifications in hand, we can look for bigger improvements.
Problem Statement
When processing hydra transform trees like this one:
40,0 1,1
o04,0 5,14,4
Hydra produces GLSL with repeated function calls. These function calls can sometimes be very expensive.
gl_FragColor = ;
In practice, hydra practitioners will make far more complex patches than this example, with many, many more repeated function calls in the resulting GLSL shader. Although the graphics driver may optimize these, I believe it is better we produce more optimal shader code to begin with.
I hypothesize that we can use variable assignment to do each calculation once, and then re-use variables as many times as needed to propagate effects up the tree.
Furthermore, given the repeated structures we have observed above, not only can hydra produce more optimal shader code, but also do so while adding almost no complexity.
Manual Optimization
Before I dive back into hydra's source code, I think it could be useful to walk through transforming an example shader by hand to see what the variable assignment form would look like. Taking the example from our problem statement:
gl_FragColor = ;
We can first extract the repeated modulateScale
expression:
vec2 st_modulate = ;
gl_FragColor = ;
Immediately, we can read the repeated structure more easily. Let's take this
further by extracting the 2 arguments of diff
into variables.
vec2 st_modulate = ;
vec4 c_osc = ;
vec4 c_mask = ;
gl_FragColor = ;
If we repeat the same process for mask
and modulateScale
:
vec4 c_voronoi = ;
vec2 st_modulate = ;
vec4 c_osc = ;
vec4 c_src = ;
vec4 c_shape = ;
vec4 c_mask = ;
gl_FragColor = ;
And now we have transformed the tree structure into something relatively flat. We can take it even 1 step further though! We can remove the final nested function calls by assigning to the same variable.
vec4 c_voronoi = ;
vec2 st_modulate = ;
vec4 c_osc = ;
c_osc = ;
vec4 c_src = ;
st_modulate = ;
vec4 c_shape = ;
c_shape = ;
c_osc = ;
gl_FragColor = c_osc;
The advantage of structuring it this way is that every transform gets its own line, and we can avoid creating too many uniquely named variables. The real function calls will have a bunch of extra parameters, so this helps us more clearly read the shader:
void
If we apply the same structure and principles to hydra's GLSL generator, we can produce more optimal shader code.
Changing the Generator
To recap, the generator takes a tree-shaped data structure and produces a matching GLSL expression. However, this results in copying branches of the tree because the hydra tree-structure and GLSL tree structure differ slightly.
In order to prevent these copies, we will flatten the tree into a more linear structure which uses variable assignment, still producing a logically identical shader.
Let's start with the part that gets duplicated the most: coord
functions.
if transform.transform.type === 'coord'
If we think again about what coord
/combineCoord
and src
/color
/combine
functions do structurally and ignore the inputs, coord
/ combineCoord
functions affect only the vec2 uv
and src
/color
/combine
affect only the
vec4 color
. We can think of these as 2 independent channels.
Only the relative ordering within each channel matters.
In other words, the following hydras produce effectively the same shader code:
o0
o1
o2
o3
We can exploit this to order our assignment statements such that all vec2 uv
"channel" functions appear first, then all vec4 color
appear last.
Due to hydra's iteration order, this boils down to pushing all coord
/
combineCoord
transforms into the list of statements in stack order, and
pushing all src
/color
/combine
transforms into the list of statements in
queue order.
However, if we look at one of the transform types which introduces branching,
we realize that the ordering of coord
and combineCoord
functions does
begin to matter:
o0 // rotates shape
o1 // does not rotate shape
If we only consider hydras with 1 head for
the moment, we can modify the template string to assign the value directly to
the uv
we pass in, and then propagate that value into f0
; the generator will
then copy the variable name around rather than the whole expression:
if transform.transform.type === 'coord'
By placing the new ${uv} = ...
expression first, we ensure the stack-like
behavior of vec2 uv
channel functions.
If we try to run this, we will run into an error; checking the generated GLSL, we see the source of the error is in the main function:
void
This comes from the template string inside glsl-source.js
:
100 `
101 void main () {
102 vec4 c = vec4(1, 0, 0, 1);
103 vec2 st = gl_FragCoord.xy/resolution.xy;
104 gl_FragColor = ;
105 }
106 `
The code which produces the calling context for the actual hydra expects a
single expression. If we also change the vec4 color
transforms to assign
to a variable, we can change the template string to this:
100 `
101 void main () {
102 vec4 c = vec4(1, 0, 0, 1);
103 vec2 st = gl_FragCoord.xy/resolution.xy;
104
105 gl_FragColor = c;
106 }
107 `
To make things less error-prone, we should also pass the name of the color
variable to our functions. Within generateGlsl
this means updating the
recursive function we are building to have 2 inputs.
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
We also need to update the default function in generate-glsl.js
:
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
If we run hydra with npm run dev
, it will open a minimal window where we
can play with hydra in the console.
Typing:
Produces a red screen instead of the osc; if we inspect the shader by typing the following fragment into the console we see:
.frag
void
We see that rotate
assigns to st
as expected, but since the src osc
function does not actually assign to the c
variable, we just keep the default
red from the top of the main function.
Let us continue by updating the src
branch of the generator:
46 fragColor = 47 ` = ;`
And re-create our simple patch in the console, it will now output the correct results:
void
Now let us try using a color
function:
3,0 1,10 2
We get a new compiler error:
void
We see now that need to update the color
branch in generateGlsl
; since
color
functions need to update the exiting color variable, we need to generate
the rest of the shader statements before the new color assignment:
53 fragColor = 54 `
55 `
= ;
Now things should work as expected with no GLSL compile errors.
With the 3 most simple function types out of the way, let's try one that
introduces branching in the transform tree: combine
functions. In principle,
the combine functions should look very similar to the color
functions since
they ultimately change the color of the shader. If we look at the combine
branch again, however:
57 // combining two generated shader strings (i.e. for blend, mult, add funtions)
58 59
60 inputs.isUniform ?inputs.name :inputs.value
61 fragColor =``
We see that recursive call to generateGlsl
. If we first apply the same
transformation we did for the color
function (mind the semicolon):
57 // combining two generated shader strings (i.e. for blend, mult, add funtions)
58 59
60 inputs.isUniform ?inputs.name :inputs.value
61 fragColor = 62 `
63
64 `
= ;
And then try the example:
Instead of seeing a shape
superimposed on top of the osc
, we see only the
shape
; intuitively, this makes sense since we have used ${c}
as both input
arguments to the shaderString
and both branches of the generator; if we check
the source of the main function now:
void
We see that the shader overwrite the osc
output. We need to introduce a new
variable to store the intermediate results of the shape().luma()
chain to
pass into the layer
function.
I messed around with a lot of schemes for naming variables, and I found the one that holds up is to use the iteration order like in the diagrams above:
shape 1.0
|
osc 0 luma 1.1
\ /
layer 1
|
out 2
Each .
represents a recursive call to generateGlsl
, and our linearized
iteration order looks like this:
node | number |
---|---|
osc | 0 |
layer | 1 |
shape | 1.0 |
luma | 1.1 |
out | 2 |
Since we visit each node exactly once, if we use the iteration to derive the name for each variable, then it will always be unique.
Let's try appending the iteration _${i}
to create a new variable:
At the top of the loop, we can add an argument for the index:
;
Then we can use it in the updated combine
branch:
61 fragColor = 62 `
63
64 `
= ;
You might remember, however, that in our src
branch we are just assigning to
an existing variable; since ${c}_${i}
doesn't exist yet, we are likely to
get a syntax error, so we need to update the src
branch:
46 fragColor = 47 `vec4 = ;`
And the template main
function (removing the definition of c
):
100 `
101 void main () {
102 vec2 st = gl_FragCoord.xy/resolution.xy;
103
104 gl_FragColor = c;
105 }
106 `
Now we can run our example again:
And observe things working as expected:
void
Let's consider a more complex example:
We would expect kaleid
to only affect the layered osc
, but it
also affects the shape
!
void
We also need to scope the uv
variable!
63 fragColor = 64 `vec2 _ = ;
65
66
67 `
= ;
Note that we cache the UV before running f1
and f0
to keep things properly
scoped. Now we get the expected result:
void
We can now confirm that we have the same behavior as before, but without repeated
rotate
calls appearing at both osc
calls and in the shape
call. It all gets
handled in:
st = ;
We have one last function type to handle before we are done, combineCoord
which
combines all observations thus far:
71 fragColor = 72 `vec2 _ = ;
73
74 = ;
75 `
And if we now try the following patch:
We can move modulateRotate
up and down and see it progressively affect more
layers; and the version with modulateRotate
after the last layer
function
will only appear once in the shader's main function:
void
Mission accomplished! There is more cleanup and work one could do. We haven't
investigated the shaderString
function that much, and notice that it has a
call to generateGlsl
inside! That will be broken.
However, I think this is a good place to stop before we go too crazy.
A last example
For fun, let us compare the before and after on this patch:
1,1,0 5
9, 3,1 5
0 2,0 2,1
0 9
o0,2
0 01
100,20 8
2,0 1,1,0 8
o0,0 5
4,0 1,0 80 8
0 1
,
2
o0,0 01
It is fairly representative of something I might arrive at during a performance, perhaps even quite a bit simpler.
Nested Expression Style
A massive wall of text, with deeply nested repeated expressions.
void
Assignment Style
No crazy nested repeated function calls, relatively easy to read.
void
Afterword
I started this post in February 2025 and sat on it, in a nearly finished state, for a very long time. The thought of an LLM scraper ingesting this page really bothered me, to the point of feeling quite depressed. I write for real people with real curiosity and passion, and when you read my site, I hope the passion and care I put into it comes through.
Thank you.
License & Acknowledgements
You can find hydra's source code online at hydra-synth
. The source code
is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE
version 3, as is the
source code for this blog.