optimize-hydra(1)

glfmn.io optimize-hydra(1)
Name

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:

osc(10,0.1,0.2)
  .color(0.1, 0.2, 0.4)
  .modulateRotate(shape(), 2)
  .diff(o0)
  .scrollY(0.1)
  .rotate(0,0.1)
  .out()

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:

osc(10).out()

This hydra generates the following glsl code, which draws a sine wave across the screen:

vec4 osc(vec2 _st, float frequency, float sync, float offset) {
  vec2 st = _st;
  float r = sin((st.x-offset/frequency+time*sync)*frequency)*0.5  + 0.5;
  float g = sin((st.x+time*sync)*frequency)*0.5 + 0.5;
  float b = sin((st.x+offset/frequency+time*sync)*frequency)*0.5  + 0.5;
  return vec4(r, g, b, 1.0);
}

void main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  gl_FragColor = osc(st, 10., 0.1, 0.);
}

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:

src/glsl/glsl-functions.js
138{
139 name: 'osc',
140 type: 'src',
141 inputs: [
142 {
143 type: 'float',
144 name: 'frequency',
145 default: 60,
146 },
147 {
148 type: 'float',
149 name: 'sync',
150 default: 0.1,
151 },
152 {
153 type: 'float',
154 name: 'offset',
155 default: 0,
156 }
157 ],
158 glsl:
159` vec2 st = _st;
160 float r = sin((st.x-offset/frequency+time*sync)*frequency)*0.5 + 0.5;
161 float g = sin((st.x+time*sync)*frequency)*0.5 + 0.5;
162 float b = sin((st.x+offset/frequency+time*sync)*frequency)*0.5 + 0.5;
163 return vec4(r, g, b, 1.0);`
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 = osc(st, 10., 0.1, 0.);

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:

src/generator-factory.js
74const typeLookup = {
75 'src': {
76 returnType: 'vec4',
77 args: ['vec2 _st']
78 },
79 'coord': {
80 returnType: 'vec2',
81 args: ['vec2 _st']
82 },
83 'color': {
84 returnType: 'vec4',
85 args: ['vec4 _c0']
86 },
87 'combine': {
88 returnType: 'vec4',
89 args: ['vec4 _c0', 'vec4 _c1']
90 },
91 'combineCoord': {
92 returnType: 'vec2',
93 args: ['vec2 _st', 'vec4 _c0']
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:

osc(10).rotate().out()

We added the rotate function which is of type coord.

void main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  gl_FragColor = osc(rotate(st, 10., 0.), 10., 0.1, 0.);
}

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:

osc(10).rotate().thresh().out()

We can see that hydra nests the osc(rotate) expression into the _c0 argument of thresh.

gl_FragColor = thresh(osc(rotate(st, 10., 0.), 10., 0.1, 0.), 0.5, 0.04);

Let's look at a more complicated example:

osc(10)
  .thresh().repeat()
  .color(0.4,0.4,0.1)
  .scrollY().rotate()
  .out()

Analyzing the GLSL with all the numbers removed, so we can see the overall structure:

gl_FragColor = color(thresh(osc(repeat(scrollY(rotate(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:

osc(10).diff(osc(12,0.2))
  .rotate()
  .thresh().out()

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 = thresh(diff(
  osc(rotate(st, 10., 0.), 10., 0.1, 0.),
  osc(rotate(st, 10., 0.), 12., 0.2, 0.))
);

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:

src/generate-glsl.js
29function generateGlsl (transforms, shaderParams) {
30 // transform function that outputs a shader string corresponding to gl_FragColor
31 var fragColor = () => ''
32 // var uniforms = []
33 // var glslFunctions = []
34 transforms.forEach((transform) => {
35 // TRUNCATED
36 })
37
38 return fragColor
39}
transforms
The tree structure that results from the hydra function chain.
shaderParams
An object which stores extra context necessary to generate the shader like uniforms.
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:

{
  "name": "osc",
  "transform": {
    "name": "osc",
    "type": "src",
    "inputs": [
      {
        "type": "float",
        "name": "frequency",
        "default": 60
      },
      {
        "type": "float",
        "name": "sync",
        "default": 0.1
      },
      {
        "type": "float",
        "name": "offset",
        "default": 0
      }
    ],
    "glsl": "..."
  },
}

If we look at the body of generateGlsls main loop, it has 3 main parts:

  1. Pre-processing inputs
  2. Adding the transform to a list of functions to lazily generate its glsl function body
  3. 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:

`src/generate-glsl.js`
44// current function for generating frag color shader code
45var f0 = fragColor
46if (transform.transform.type === 'src') {
47 fragColor = (uv) => `${shaderString(uv, transform.name, inputs, shaderParams)}`
48} else if (transform.transform.type === 'coord') {
49 fragColor = (uv) => `${f0(`${shaderString(uv, transform.name, inputs, shaderParams)}`)}`
50} else if (transform.transform.type === 'color') {
51 fragColor = (uv) => `${shaderString(`${f0(uv)}`, transform.name, inputs, shaderParams)}`
52} else if (transform.transform.type === 'combine') {
53 // combining two generated shader strings (i.e. for blend, mult, add funtions)
54 var f1 = inputs[0].value && inputs[0].value.transforms ?
55 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
56 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
57 fragColor = (uv) => `${shaderString(`${f0(uv)}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`
58} else if (transform.transform.type === 'combineCoord') {
59 // combining two generated shader strings (i.e. for modulate functions)
60 var f1 = inputs[0].value && inputs[0].value.transforms ?
61 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
62 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
63 fragColor = (uv) => `${f0(`${shaderString(`${uv}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`)}`
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:

`src/generate-glsl.js`
46if (transform.transform.type === 'src') {
47 fragColor = (uv) => `${shaderString(uv, transform.name, inputs, shaderParams)}`
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': {
  returnType: 'vec4',
  args: ['vec2 _st']
},

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:

`src/generate-glsl.js`
50} else if (transform.transform.type === 'color') {
51 fragColor = (uv) => `${shaderString(`${f0(uv)}`, transform.name, inputs, shaderParams)}`
52}

We see that the shaderString gets f0 the result of passed in as its parameter; if we consult the typeLookup again:

`src/generator-factory.js`
83'color': {
84 returnType: 'vec4',
85 args: ['vec4 _c0']
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:

`src/generate-glsl.js`
46} else if (transform.transform.type === 'coord') {
47 fragColor = (uv) => `${f0(`${shaderString(uv, transform.name, inputs, shaderParams)}`)}`
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:

`src/generate-glsl.js`
52} else if (transform.transform.type === 'combine') {
53 // combining two generated shader strings (i.e. for blend, mult, add funtions)
54 var f1 = inputs[0].value && inputs[0].value.transforms ?
55 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
56 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
57 fragColor = (uv) => `${shaderString(`${f0(uv)}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`
58}

If we look at the typeLookup definition, the input field actually defines 2 input variables:

`src/generator-factory.js`
87'combine': {
88 returnType: 'vec4',
89 args: ['vec4 _c0', 'vec4 _c1']
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:

`src/generator-factory.js`
58} else if (transform.transform.type === 'combineCoord') {
59 // combining two generated shader strings (i.e. for modulate functions)
60 var f1 = inputs[0].value && inputs[0].value.transforms ?
61 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
62 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
63 fragColor = (uv) => `${f0(`${shaderString(`${uv}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`)}`
64}

Again looking at the typeLookup data:

`src/generator-factory.js`
91'combineCoord': {
92 returnType: 'vec2',
93 args: ['vec2 _st', 'vec4 _c0']
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:

osc(40,0.1,1).saturate()
  .diff(src(o0).mask(shape(4,0.5,1).repeat(4,4)))
  .modulateScale(voronoi())
  .out()

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 = diff(
  saturate(osc(modulateScale(st, voronoi(st)))),
  mask(
    src(modulateScale(st, voronoi(st)), tex0),
    shape(repeat(modulateScale(st, voronoi(st))))
  )
);

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:

`src/generate-glsl.js`
52} else if (transform.transform.type === 'combine') {
53 // combining two generated shader strings (i.e. for blend, mult, add funtions)
54 var f1 = inputs[0].value && inputs[0].value.transforms ?
55 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
56 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
57 fragColor = (uv) => `${shaderString(`${f0(uv)}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`
58} else if (transform.transform.type === 'combineCoord') {
59 // combining two generated shader strings (i.e. for modulate functions)
60 var f1 = inputs[0].value && inputs[0].value.transforms ?
61 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
62 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
63 fragColor = (uv) => `${f0(`${shaderString(`${uv}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`)}`
64}

If we look at the fragColor expressions specifically:

// combine
fragColor = (uv) => `${shaderString(`${f0(uv)}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`

// combineCoord
fragColor = (uv) => `${f0(`${shaderString(`${uv}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`)}`

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:

src/genreate-glsl.js
73// assembles a shader string containing the arguments and the function name,
74// i.e. 'osc(uv, frequency)'
75function shaderString (uv, method, inputs, shaderParams) {
76 const str = inputs.map((input) => {
77 if (input.isUniform) {
78 return input.name
79 } else if (input.value && input.value.transforms) {
80 // this by definition needs to be a generator, hence we start with 'st'
81 // as the initial value for generating the glsl fragment
82 return `${generateGlsl(input.value.transforms, shaderParams)('st')}`
83 }
84 return input.value
85 }).reduce((p, c) => `${p}, ${c}`, '')
86 return `${method}(${uv}${str})`
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:

`src/generate-glsl.js`
52} else if (transform.transform.type === 'combine') {
53 // combining two generated shader strings (i.e. for blend, mult, add funtions)
54 var f1 = inputs[0].value && inputs[0].value.transforms ?
55 (uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(uv)}` :
56 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
57 fragColor = (uv) => `${shaderString(`${f0(uv)}, ${f1(uv)}`, transform.name, inputs.slice(1), shaderParams)}`
58}

The branch we see here using the ternary (? :) is effectively identical to how shaderString already processes every input:

src/genreate-glsl.js
75 if (input.isUniform) {
76 return input.name
77 } else if (input.value && input.value.transforms) {
78 // this by definition needs to be a generator, hence we start with 'st'
79 // as the initial value for generating the glsl fragment
80 return `${generateGlsl(input.value.transforms, shaderParams)('st')}`
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:

src/genreate-glsl.js
75 if (input.isUniform) {
76 return input.name
77 } else if (input.value && input.value.transforms) {
78 return `${generateGlsl(input.value.transforms, shaderParams)(uv)}`
79 }
80 return input.value

And in generateGlsl, if we allow shaderString to handle all input variables:

`src/generate-glsl.js`
44// current function for generating frag color shader code
45var f0 = fragColor
46if (transform.transform.type === 'src') {
47 fragColor = (uv) => `${shaderString(uv, transform.name, inputs, shaderParams)}`
48} else if (transform.transform.type === 'coord') {
49 fragColor = (uv) => `${f0(`${shaderString(uv, transform.name, inputs, shaderParams)}`)}`
50} else if (transform.transform.type === 'color') {
51 fragColor = (uv) => `${shaderString(`${f0(uv)}`, transform.name, inputs, shaderParams)}`
52} else if (transform.transform.type === 'combine') {
53 fragColor = (uv) => `${shaderString(`${f0(uv)}`, transform.name, inputs, shaderParams)}`
54} else if (transform.transform.type === 'combineCoord') {
55 fragColor = (uv) => `${f0(`${shaderString(`${uv}`, transform.name, inputs, shaderParams)}`)}`
56}

Several of the function categories actually collapse to the same structure:

  • coord and combineCoord are exactly the same
  • color and combine 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:

src/glsl/glsl-functions.js
78{
79 name: 'noise',
80 type: 'src',
81 inputs: [
82 {
83 type: 'float',
84 name: 'scale',
85 default: 10,
86 },
87{
88 type: 'float',
89 name: 'offset',
90 default: 0.1,
91 }
92 ],
93 glsl:
94` return vec4(vec3(_noise(vec3(_st*scale, offset*time))), 1.0);`
95}
src/generator-factory.js
75 'src': {
76 returnType: 'vec4',
77 args: ['vec2 _st']
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.

src/generator-factory.js
135function processGlsl(obj) {
136 let t = typeLookup[obj.type]
137 if(t) {
138 let baseArgs = t.args.map((arg) => arg).join(", ")
139 // @todo: make sure this works for all input types, add validation
140 let customArgs = obj.inputs.map((input) => `${input.type} ${input.name}`).join(', ')
141 let args = `${baseArgs}${customArgs.length > 0 ? ', '+ customArgs: ''}`
142 // console.log('args are ', args)
143
144 let glslFunction =
145`
146 ${t.returnType} ${obj.name}(${args}) {
147 ${obj.glsl}
148 }
149`
150
151 // add extra input to beginning for backward combatibility @todo update compiler so this is no longer necessary
152 if(obj.type === 'combine' || obj.type === 'combineCoord') obj.inputs.unshift({
153 name: 'color',
154 type: 'vec4' })
155 return Object.assign({}, obj, { glsl: glslFunction})
156 } else {
157 console.warn(`type ${obj.type} not recognized`, obj)
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:

src/generator-factory.js
152// add extra input to beginning for backward combatibility @todo update compiler so this is no longer necessary
153if(obj.type === 'combine' || obj.type === 'combineCoord') obj.inputs.unshift({
154 name: 'color',
155 type: 'vec4' })

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:

src/generator-factory.js
74const typeLookup = {
75 'src': {
76 returnType: 'vec4',
77 args: [{ type: 'vec2', name: '_st' }]
78 },
79 'coord': {
80 returnType: 'vec2',
81 args: [{ type: 'vec2', name: '_st'}]
82 },
83 'color': {
84 returnType: 'vec4',
85 args: [{ type: 'vec4', name: '_c0'}]
86 },
87 'combine': {
88 returnType: 'vec4',
89 args: [
90 { type: 'vec4', name: '_c0'},
91 { type: 'vec4', name: '_c1'}
92 ]
93 },
94 'combineCoord': {
95 returnType: 'vec2',
96 args: [
97 { type: 'vec2', name: '_st'},
98 { type: 'vec4', name: '_c0'},
99 ]
100 }
101}

Then we can just directly concatenate the input lists in generateGlsl, removing a lot of special-case code:

src/generator-factory.js
141function processGlsl(obj) {
142 let t = typeLookup[obj.type]
143 if(t) {
144 let inputs = t.args.concat(obj.inputs);
145 let args = inputs.map((input) => `${input.type} ${input.name}`).join(', ')
146 // console.log('args are ', args)
147
148 let glslFunction =
149`
150 ${t.returnType} ${obj.name}(${args}) {
151 ${obj.glsl}
152 }
153`
154 // First input gets specially handled
155 obj.inputs = inputs.slice(1);
156
157 return Object.assign({}, obj, { glsl: glslFunction})
158 } else {
159 console.warn(`type ${obj.type} not recognized`, obj, typeLookup)
160 }
161}

With these simplifications in hand, we can look for bigger improvements.

Problem Statement

When processing hydra transform trees like this one:

osc(40,0.1,1).saturate()
  .diff(src(o0).mask(shape(4,0.5,1).repeat(4,4)))
  .modulateScale(voronoi())
  .out()

Hydra produces GLSL with repeated function calls. These function calls can sometimes be very expensive.

gl_FragColor = diff(
  saturate(osc(modulateScale(st, voronoi(st)))),
  mask(
    src(modulateScale(st, voronoi(st)), tex0),
    shape(repeat(modulateScale(st, voronoi(st))))
  )
);

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 = diff(
  saturate(osc(modulateScale(st, voronoi(st)))),
  mask(
    src(modulateScale(st, voronoi(st)), tex0),
    shape(repeat(modulateScale(st, voronoi(st))))
  )
);

We can first extract the repeated modulateScale expression:

vec2 st_modulate = modulateScale(st, voronoi(st));
gl_FragColor = diff(
  saturate(osc(st_modulate)),
  mask(
    src(st_modulate, tex0),
    shape(repeat(st_modulate))
  )
);

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 = modulateScale(st, voronoi(st));
vec4 c_osc = saturate(osc(st_modulate));
vec4 c_mask = mask(
    src(st_modulate, tex0),
    shape(repeat(st_modulate))
);
gl_FragColor = diff(c_osc, c_mask);

If we repeat the same process for mask and modulateScale:

vec4 c_voronoi = voronoi(st);
vec2 st_modulate = modulateScale(st, c_voronoi);
vec4 c_osc = saturate(osc(st_modulate));
vec4 c_src = src(st_modulate, tex0);
vec4 c_shape = shape(repeat(st_modulate));
vec4 c_mask = mask(c_src, c_shape);
gl_FragColor = diff(c_osc, c_mask);

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 = voronoi(st);
vec2 st_modulate = modulateScale(st, c_voronoi);
vec4 c_osc = osc(st_modulate);
c_osc = saturate(c_osc);
vec4 c_src = src(st_modulate, tex0);
st_modulate = repeat(st_modulate);
vec4 c_shape = shape(st_modulate);
c_shape = mask(c_src, c_shape);
c_osc = diff(c_osc, c_mask);
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 main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;

  vec4 c_voronoi = voronoi(st, 5., 0.3, 0.3);
  st = modulateScale(st, c_voronoi, 1., 1.);
  vec4 c_osc = osc(st, 40., 0.1, 1.);
  c_osc = staturate(c_osc, 2.);
  vec4 c_src = src(st, tex0);
  st = repeat(st, 4., 4., 0., 0.);
  vec4 c_shape = shape(st, 4., 0.5, 1.);
  c_shape= mask(c_src, c_shape);
  c_osc = diff(c_osc, c_shape);
  gl_FragColor = c_osc;
}

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.

`src/generate-glsl.js`
if (transform.transform.type === 'coord') {
  fragColor = (uv) => `${f0(`${shaderString(uv, transform.name, inputs, shaderParams)}`)}`
}

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:

render()

osc().saturate().colorama().rotate().scale().out(o0)

osc().saturate().rotate().colorama().scale().out(o1)

osc().rotate().saturate().scale().colorama().out(o2)

osc().rotate().scale().saturate().colorama().out(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:

render()

osc().add(shape()).rotate().out(o0) // rotates shape

osc().rotate().add(shape()).out(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:

`src/generate-glsl.js`
if (transform.transform.type === 'coord') {
  fragColor = (uv) => `${uv} = ${shaderString(uv, transform.name, inputs, shaderParams)};
  ${f0(uv)}`
}

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 main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  gl_FragColor = st = rotate(st, 10., 0.);
  osc(st, 60., 0.1, 0.);
}

This comes from the template string inside glsl-source.js:

src/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 = ${shaderInfo.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:

src/glsl-source.js
100 `
101 void main () {
102 vec4 c = vec4(1, 0, 0, 1);
103 vec2 st = gl_FragCoord.xy/resolution.xy;
104 ${shaderInfo.fragColor}
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.

src/generate-glsl.js
29function generateGlsl (transforms, shaderParams) {
30 // transform function that outputs a shader string corresponding to gl_FragColor
31 var fragColor = (c,uv) => ''
32 // var uniforms = []
33 // var glslFunctions = []
34 transforms.forEach((transform) => {
35 var inputs = formatArguments(transform, shaderParams.uniforms.length)
36 inputs.forEach((input) => {
37 if(input.isUniform) shaderParams.uniforms.push(input)
38 })
39
40 // add new glsl function to running list of functions
41 if(!contains(transform, shaderParams.glslFunctions)) shaderParams.glslFunctions.push(transform)
42
43 // current function for generating frag color shader code
44 var f0 = fragColor
45 if (transform.transform.type === 'src') {
46 fragColor = (c,uv) => `${shaderString(uv, transform.name, inputs, shaderParams)}`
47 } else if (transform.transform.type === 'coord') {
48 fragColor = (c,uv) => `${uv} = ${shaderString(uv, transform.name, inputs, shaderParams)};
49 ${f0(c,uv)}`
50 } else if (transform.transform.type === 'color') {
51 fragColor = (c,uv) => `${shaderString(`${f0(c,uv)}`, transform.name, inputs, shaderParams)}`
52 } else if (transform.transform.type === 'combine') {
53 // combining two generated shader strings (i.e. for blend, mult, add funtions)
54 var f1 = inputs[0].value && inputs[0].value.transforms ?
55 (c,uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(c,uv)}` :
56 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
57 fragColor = (c,uv) => `${shaderString(`${f0(c,uv)}, ${f1(c,uv)}`, transform.name, inputs.slice(1), shaderParams)}`
58 } else if (transform.transform.type === 'combineCoord') {
59 // combining two generated shader strings (i.e. for modulate functions)
60 var f1 = inputs[0].value && inputs[0].value.transforms ?
61 (c,uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(c,uv)}` :
62 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
63 fragColor = (c,uv) => `${f0(`${shaderString(`${uv}, ${f1(c,uv)}`, transform.name, inputs.slice(1), shaderParams)}`)}`
64 }
65 })
66 return fragColor
67}

We also need to update the default function in generate-glsl.js:

src/generate-glsl.js
9export default function (transforms) {
10 var shaderParams = {
11 uniforms: [], // list of uniforms used in shader
12 glslFunctions: [], // list of functions used in shader
13 fragColor: ''
14 }
15
16 var gen = generateGlsl(transforms, shaderParams)('c','st')
17 shaderParams.fragColor = gen
18 // remove uniforms with duplicate names
19 let uniforms = {}
20 shaderParams.uniforms.forEach((uniform) => uniforms[uniform.name] = uniform)
21 shaderParams.uniforms = Object.values(uniforms)
22 return shaderParams
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:

osc().rotate().out()

Produces a red screen instead of the osc; if we inspect the shader by typing the following fragment into the console we see:

osc().rotate().glsl()[0].frag
void main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  st = rotate(st, 10., 0.);
    osc(st, 60., 0.1, 0.);
  gl_FragColor = c;
}

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:

src/generate-glsl.js
46fragColor = (c,uv) =>
47 `${c} = ${shaderString(`${c}${i}`, uv, transform.name, inputs)};`

And re-create our simple patch in the console, it will now output the correct results:

void main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  st = rotate(st, 10., 0.);
  c = osc(st, 60., 0.1, 0.);
  gl_FragColor = c;
}

Now let us try using a color function:

osc(3,0.1,1).rotate().hue(0.2).out()

We get a new compiler error:

void main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  hue(st = rotate(st, 10., 0.);
  c = osc(st, 3., 0.1, 2.), 0.2);
  gl_FragColor = c;
}

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:

src/generate-glsl.js
53fragColor = (c,uv) =>
54 `${f0(c, uv)}
55 ${c} = ${shaderString(`${c}`, transform.name, inputs, shaderParams)};`

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:

src/generate-glsl.js
57// combining two generated shader strings (i.e. for blend, mult, add funtions)
58var f1 = inputs[0].value && inputs[0].value.transforms ?
59 (c,uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(c,uv)}` :
60 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
61fragColor = (c,uv) => `${shaderString(`${f0(c,uv)}, ${f1(c,uv)}`, transform.name, inputs.slice(1), shaderParams)}`

We see that recursive call to generateGlsl. If we first apply the same transformation we did for the color function (mind the semicolon):

src/generate-glsl.js
57// combining two generated shader strings (i.e. for blend, mult, add funtions)
58var f1 = inputs[0].value && inputs[0].value.transforms ?
59 (c,uv) => `${generateGlsl(inputs[0].value.transforms, shaderParams)(c,uv)}` :
60 (inputs[0].isUniform ? () => inputs[0].name : () => inputs[0].value)
61fragColor = (c,uv) =>
62 `${f0(c,uv)}
63 ${f1(c,uv)}
64 ${c} = ${shaderString(`${c}, ${c}`, transform.name, inputs.slice(1), shaderParams)};`

And then try the example:

osc().layer(shape().luma()).out()

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 main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
  c = osc(st, 60., 0.1, 0.);
  c = shape(st, 3., 0.3, 0.01); // discards osc
  c = luma(c, 0.5, 0.1);
  c = layer(c, c);
gl_FragColor = c;

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:

nodenumber
osc0
layer1
shape1.0
luma1.1
out2

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:

src/generate_glsl.js
transforms.forEach((transform, i) => {
  // truncated
});

Then we can use it in the updated combine branch:

src/generate_glsl.js
61fragColor = (c,uv) =>
62 `${f0(c,uv)}
63 ${f1(`${c}_${i}`,uv)}
64 ${c} = ${shaderString(`${c}, ${c}_${i}`, transform.name, inputs.slice(1), shaderParams)};`

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:

src/generate_glsl.js
46fragColor = (c,uv) =>
47 `vec4 ${c} = ${shaderString(uv, transform.name, inputs, shaderParams)};`

And the template main function (removing the definition of c):

src/glsl-source.js
100 `
101 void main () {
102 vec2 st = gl_FragCoord.xy/resolution.xy;
103 ${shaderInfo.fragColor}
104 gl_FragColor = c;
105 }
106`

Now we can run our example again:

osc().layer(shape().luma()).out()

And observe things working as expected:

void main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;
  vec4 c = osc(st, 60., 0.1, 0.);
  vec4 c_1 = shape(st, 3., 0.3, 0.01);
  c_1 = luma(c_1, 0.5, 0.1);
  c = layer(c, c_1);
  gl_FragColor = c;
}

Let's consider a more complex example:

osc()
  .layer(osc().luma().kaleid())
  .layer(shape().luma())
  .rotate()

We would expect kaleid to only affect the layered osc, but it also affects the shape!

void main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;
  st = rotate(st, 10., 0.);
  vec4 c = osc(st, 60., 0.1, 0.);
  st = kaleid(st, 4.); // observable by `shape`
  vec4 c_1 = osc(st, 60., 0.1, 0.);
  c_1 = luma(c_1, 0.5, 0.1);
  c = layer(c, c_1);
  vec4 c_2 = shape(st, 3., 0.3, 0.01);
  c_2 = luma(c_2, 0.5, 0.1);
  c = layer(c, c_2);
  gl_FragColor = c;
}

We also need to scope the uv variable!

src/generate_glsl.js
63fragColor = (c,uv) =>
64 `vec2 ${uv}_${i} = ${uv};
65 ${f1(`${c}_${i}`,`${uv}_${i}`)}
66 ${f0(c,uv)}
67 ${c} = ${shaderString(`${c}, ${c}_${i}`, transform.name, inputs.slice(1), shaderParams)};`

Note that we cache the UV before running f1 and f0 to keep things properly scoped. Now we get the expected result:

void main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;
  st = rotate(st, 10., 0.);
  vec2 st_2 = st;
  vec4 c_2 = shape(st_2, 3., 0.3, 0.01);
  c_2 = luma(c_2, 0.5, 0.1);
  vec2 st_1 = st;
  st_1 = kaleid(st_1, 4.); // properly scoped
  vec4 c_1 = osc(st_1, 60., 0.1, 0.);
  c_1 = luma(c_1, 0.5, 0.1);
  vec4 c = osc(st, 60., 0.1, 0.);
  c = layer(c, c_1);
  c = layer(c, c_2);
  gl_FragColor = c;
}

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 = rotate(st, 10., 0.);

We have one last function type to handle before we are done, combineCoord which combines all observations thus far:

src/generate_glsl.js
71fragColor = (c,uv) =>
72 `vec2 ${uv}_${i} = ${uv};
73 ${f1(`${c}_${i}`,`${uv}_${i}`)}
74 ${uv} = ${shaderString(`${uv}, ${c}_${i}`, transform.name, inputs.slice(1), shaderParams)};
75 ${f0(c,uv)}`

And if we now try the following patch:

osc()
  .layer(osc().luma().kaleid())
  .layer(shape().luma())
  .modulateRotate(voronoi())
  .rotate()

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 main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;
  st = rotate(st, 10., 0.);
  vec2 st_3 = st;
  vec4 c_3 = voronoi(st_3, 5., 0.3, 0.3);
  st = modulateRotate(st_3, c_3, 1., 0.);
  vec2 st_2 = st;
  vec4 c_2 = shape(st_2, 3., 0.3, 0.01);
  c_2 = luma(c_2, 0.5, 0.1);
  vec2 st_1 = st;
  st_1 = kaleid(st_1, 4.);
  vec4 c_1 = osc(st_1, 60., 0.1, 0.);
  c_1 = luma(c_1, 0.5, 0.1);
  vec4 c = osc(st, 60., 0.1, 0.);
  c = layer(c, c_1);
  c = layer(c, c_2);
  gl_FragColor = c;
}

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:

shape().color(1,1,0.5)
  .repeat()
  .sub(voronoi(9,.3,1.5)
       .color(0.2,0.2,1))
  .hue(0.9)
  .modulate(o0,2)
  .scrollX(0.01)
  .layer(noise(100,2).luma(0.8))
  .modulateScale(
  	voronoi(2,0.1,1),0.8)
  .add(o0,0.5)
  .modulate(shape(4,0.1,0.8).scale(0.8)
    .scrollX(0.1)
    .kaleid(), [8,3].smooth())
  .saturate(2)
  .modulateScrollX(o0,0.01)
  .out()  

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 main () {
  vec4 c = vec4(1, 0, 0, 1);
  vec2 st = gl_FragCoord.xy/resolution.xy;
    gl_FragColor = saturate(add(layer(hue(sub(
        color(
            shape(
                repeat(
                    modulate(
                        scrollX(
                            modulateScale(
                                modulate(
                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                    shape(
                                        scale(
                                            scrollX(
                                                kaleid(
                                                    modulateScrollX(
                                                        st,
                                                        src(st, tex1),
                                                        0.01,
                                                        0.0,
                                                    ),
                                                    4.0,
                                                ),
                                                0.1,
                                                0.0,
                                            ),
                                            0.8,
                                            1.0,
                                            1.0,
                                            0.5,
                                            0.5,
                                        ),
                                        4.0,
                                        0.1,
                                        0.8,
                                    ),
                                    amount0,
                                ),
                                voronoi(
                                    modulate(
                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                        shape(
                                            scale(
                                                scrollX(
                                                    kaleid(
                                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                        4.0,
                                                    ),
                                                    0.1,
                                                    0.0,
                                                ),
                                                0.8,
                                                1.0,
                                                1.0,
                                                0.5,
                                                0.5,
                                            ),
                                            4.0,
                                            0.1,
                                            0.8,
                                        ),
                                        amount0,
                                    ),
                                    2.0,
                                    0.1,
                                    1.0,
                                ),
                                0.8,
                                1.0,
                            ),
                            0.01,
                            0.0,
                        ),
                        src(
                            scrollX(
                                modulateScale(
                                    modulate(
                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                        shape(
                                            scale(
                                                scrollX(
                                                    kaleid(
                                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                        4.0,
                                                    ),
                                                    0.1,
                                                    0.0,
                                                ),
                                                0.8,
                                                1.0,
                                                1.0,
                                                0.5,
                                                0.5,
                                            ),
                                            4.0,
                                            0.1,
                                            0.8,
                                        ),
                                        amount0,
                                    ),
                                    voronoi(
                                        modulate(
                                            modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                            shape(
                                                scale(
                                                    scrollX(
                                                        kaleid(
                                                            modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                            4.0,
                                                        ),
                                                        0.1,
                                                        0.0,
                                                    ),
                                                    0.8,
                                                    1.0,
                                                    1.0,
                                                    0.5,
                                                    0.5,
                                                ),
                                                4.0,
                                                0.1,
                                                0.8,
                                            ),
                                            amount0,
                                        ),
                                        2.0,
                                        0.1,
                                        1.0,
                                    ),
                                    0.8,
                                    1.0,
                                ),
                                0.01,
                                0.0,
                            ),
                            tex2,
                        ),
                        2.0,
                    ),
                    3.0,
                    3.0,
                    0.0,
                    0.0,
                ),
                3.0,
                0.3,
                0.01,
            ),
            1.0,
            1.0,
            0.5,
            1.0,
        ),
        color(
            voronoi(
                modulate(
                    scrollX(
                        modulateScale(
                            modulate(
                                modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                shape(
                                    scale(
                                        scrollX(
                                            kaleid(
                                                modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                4.0,
                                            ),
                                            0.1,
                                            0.0,
                                        ),
                                        0.8,
                                        1.0,
                                        1.0,
                                        0.5,
                                        0.5,
                                    ),
                                    4.0,
                                    0.1,
                                    0.8,
                                ),
                                amount0,
                            ),
                            voronoi(
                                modulate(
                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                    shape(
                                        scale(
                                            scrollX(
                                                kaleid(
                                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                    4.0,
                                                ),
                                                0.1,
                                                0.0,
                                            ),
                                            0.8,
                                            1.0,
                                            1.0,
                                            0.5,
                                            0.5,
                                        ),
                                        4.0,
                                        0.1,
                                        0.8,
                                    ),
                                    amount0,
                                ),
                                2.0,
                                0.1,
                                1.0,
                            ),
                            0.8,
                            1.0,
                        ),
                        0.01,
                        0.0,
                    ),
                    src(
                        scrollX(
                            modulateScale(
                                modulate(
                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                    shape(
                                        scale(
                                            scrollX(
                                                kaleid(
                                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                    4.0,
                                                ),
                                                0.1,
                                                0.0,
                                            ),
                                            0.8,
                                            1.0,
                                            1.0,
                                            0.5,
                                            0.5,
                                        ),
                                        4.0,
                                        0.1,
                                        0.8,
                                    ),
                                    amount0,
                                ),
                                voronoi(
                                    modulate(
                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                        shape(
                                            scale(
                                                scrollX(
                                                    kaleid(
                                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                                        4.0,
                                                    ),
                                                    0.1,
                                                    0.0,
                                                ),
                                                0.8,
                                                1.0,
                                                1.0,
                                                0.5,
                                                0.5,
                                            ),
                                            4.0,
                                            0.1,
                                            0.8,
                                        ),
                                        amount0,
                                    ),
                                    2.0,
                                    0.1,
                                    1.0,
                                ),
                                0.8,
                                1.0,
                            ),
                            0.01,
                            0.0,
                        ),
                        tex2,
                    ),
                    2.0,
                ),
                9.0,
                0.3,
                1.5,
            ),
            0.2,
            0.2,
            1.0,
            1.0,
        ),
        1.0,
    ), 0.9), luma(
        noise(
            modulateScale(
                modulate(
                    modulateScrollX(st, src(st, tex1), 0.01, 0.0,),
                    shape(
                        scale(
                            scrollX(
                                kaleid(
                                    modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                    4.0,
                                ),
                                0.1,
                                0.0,
                            ),
                            0.8,
                            1.0,
                            1.0,
                            0.5,
                            0.5,
                        ),
                        4.0,
                        0.1,
                        0.8,
                    ),
                    amount0,
                ),
                voronoi(
                    modulate(
                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                        shape(
                            scale(
                                scrollX(
                                    kaleid(
                                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                                        4.0,
                                    ),
                                    0.1,
                                    0.0,
                                ),
                                0.8,
                                1.0,
                                1.0,
                                0.5,
                                0.5,
                            ),
                            4.0,
                            0.1,
                            0.8,
                        ),
                        amount0,
                    ),
                    2.0,
                    0.1,
                    1.0,
                ),
                0.8,
                1.0,
            ),
            1000.0,
            2.0,
        ),
        0.8,
        0.1,
    )), src(modulate(
        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
        shape(
            scale(
                scrollX(
                    kaleid(
                        modulateScrollX(st, src(st, tex1), 0.01, 0.0),
                        4.0,
                    ),
                    0.1,
                    0.0,
                ),
                0.8,
                1.0,
                1.0,
                0.5,
                0.5,
            ),
            4.0,
            0.1,
            0.8,
        ),
        amount0,
    ), tex3), 0.5), 2.0);
}

Assignment Style

No crazy nested repeated function calls, relatively easy to read.

void main () {
  vec2 st = gl_FragCoord.xy/resolution.xy;
  vec2 st_12 = st;
  vec4 c_12 = src(st_12, tex1);
  st = modulateScrollX(st, c_12, 0.01, 0.);
  vec2 st_10 = st;
  st_10 = kaleid(st_10, 4.);
  st_10 = scrollX(st_10, 0.1, 0.);
  st_10 = scale(st_10, 0.8, 1., 1., 0.5, 0.5);
  vec4 c_10 = shape(st_10, 4., 0.1, 0.8);
  st = modulate(st, c_10, amount0);
  vec2 st_9 = st;
  vec4 c_9 = src(st_9, tex2);
  vec2 st_8 = st;
  vec4 c_8 = voronoi(st_8, 2., 0.1, 1.);
  st = modulateScale(st, c_8, 0.8, 1.);
  vec2 st_7 = st;
  vec4 c_7 = noise(st_7, 100., 2.);
  c_7 = luma(c_7, 0.8, 0.1);
  st = scrollX(st, 0.01, 0.);
  vec2 st_5 = st;
  vec4 c_5 = src(st_5, tex3);
  st = modulate(st, c_5, 2.);
  vec2 st_3 = st;
  vec4 c_3 = voronoi(st_3, 9., 0.3, 1.5);
  c_3 = color(c_3, 0.2, 0.2, 1., 1.);
  st = repeat(st, 3., 3., 0., 0.);
  vec4 c = shape(st, 3., 0.3, 0.01);
  c = color(c, 1., 1., 0.5, 1.);
  c = sub(c, c_3, 1.);
  c = hue(c, 0.9);
  c = layer(c, c_7);
  c = add(c, c_9, 0.5);
  c = saturate(c, 2.);;
  gl_FragColor = c;
}

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.