Converting GLSL Shaders to Core Image Kernels

Websites like ShaderToy, Twigl.app and the newer FragCoord.xyz are great repositories of fragment shaders, providing inspiring content to learn from.

The shaders found on these websites are based on the OpenGL Shading Language, aka GLSL. This guide will help you convert one of these shaders to run as a Core Image Kernel using FxCore.

Basic Approach

Aside from language differences, a big part of the conversion process is providing information to these shaders that is normally provided by their environment. The current fragment coordinate may be called gl_FragCoord by FragCoord.xyz, fragCoord by ShaderToy, or FC by Twigl (in code-golfing modes) but in all cases this is expected to be in pixel-based units. Similarly, shaders expect to know the size of the viewport (u_resolution, iResolution, etc.) These uniforms must be provided either by equivalent Core Image constructs or by feeding information to each shader via a parameter.

It might seem tempting to convert these shaders to the original Core Image Shading Language, itself a variant of GLSL. Unfortunately the original Core Image Shading Language was based on an older GLSL specification, and as such it is easier to run into limitations. Metal, on the other hand, provides a far more capable target for the conversion. Metal-based Core Image kernels benefit from a number of type definitions that conveniently map GLSL data types (e.g. vec3, mat2) to their Metal equivalents (float3, float2x2), reducing the amount of work required to port kernels.

Let’s Begin!

For this tutorial, I picked one the fantastic shaders written by Xor. Despite its small size, it provides a chance to discuss important obstacles in the conversion process.

Fire
Fire

GLSL Source from FragCoord.xyz

The source code for this shader is available on https://fragcoord.xyz/s/3zoe0vgo.

void main()
{
    fragColor = vec4(0.0);
    for(float i, z, d, j; i++ < 5e1; fragColor += (sin(z / 3.0 + vec4(7, 2, 3, 0)) + 1.1) / d)
    {
        vec3 p = z * normalize(gl_FragCoord.rgb * 2.0 - vec3(u_resolution, 1.0).xyy);
        p.z += 5.0 + cos(u_time);
        p.xz *= mat2(cos(u_time+p.y * 0.5 + vec4(0, 33, 11, 0))) / max(p.y * 0.1 + 1.0, 0.1);
        for(j = 2.0; j < 15.0; j /= 0.6)
        {
            p += cos((p.yzx - vec3(u_time, 0, 0) / 0.1) * j + u_time) / j;
        }
        z += d = 0.01 + abs(length(p.xz) + p.y * 0.3 - 0.5) / 7.0;
    }
    fragColor = tanh(fragColor / 1e3);
}

Core Image Metal Kernel

The equivalent Metal shader:

[[stitchable]] float4 fireKernel(vec2 u_resolution, float u_time, destination dest) {
    const vec4 gl_FragCoord = vec4(dest.coord(), 0.5, 0.0);
    vec4 fragColor = vec4(0.0);
    for(float i=0, z=0, d=0, j=0; i++ < 5e1; fragColor += (sin(z / 3.0 + vec4(7, 2, 3, 0)) + 1.1) / d) {
        vec3 p = z * normalize(gl_FragCoord.rgb * 2.0 - vec3(u_resolution, 1.0).xyy);
        p.z += 5.0 + cos(u_time);
        vec4 m = cos(u_time+p.y * 0.5 + vec4(0, 33, 11, 0));
        mat2 n = mat2(m.x, m.y, m.z, m.w);
        float v = max(p.y * 0.1 + 1.0, 0.1);
        p.xz = p.xz * (n / v);        
        for(j = 2.0; j < 15.0; j /= 0.6) {
            p += cos((p.yzx - vec3(u_time, 0, 0) / 0.1) * j + u_time) / j;
        }
        z += d = 0.01 + abs(length(p.xz) + p.y * 0.3 - 0.5) / 7.0;
    }
    fragColor = tanh(fragColor / 1e3);
    return fragColor;
}

The finished result, as a composition, can be downloaded here:

Fire

Line-by-line Explanation

The main entry point must be replaced by:

[[stitchable]] float4 fireKernel(vec2 u_resolution, float u_time, destination dest)

Where [[stitchable]] is an attribute required for all Metal functions whose purpose is to be compiled as a Core Image Kernel. The u_resolution parameter stands in for the built-in uniform filled with the width and height of the viewport, in pixels. The u_time parameter is used to pass “wall time”. Both are implementation details of FragCoord.xyz, and fed as parameters to the shader from outputs of the appropriate nodes:

Wait a sec…

Anyone with prior experience writing Metal shaders may recognize that a lot seems to be missing from the source code entered in FxCore. This is because the Custom Shader node automatically wraps your source code within common boilerplate required to write Core Image kernels:

#include <metal_stdlib>
#include <CoreImage/CoreImage.h>
# using namespace metal;
extern "C" { 
  namespace coreimage {
    <your shader>
  }
}

The web-based shader provides a built-in 4-element vector called gl_FragCoord whose X and Y contain coordinates of the pixel being rendered, in pixel-based units. Core Image only deals with 2D coordinates. The current output coordinate is available by adding a destination parameter as the last parameter to the shader. Because the GLSL shader expects Z and W components to be available, we fill those in with default values that match the defaults used by the particular rendering environment on FragCoord.xyz:

const vec4 gl_FragCoord = vec4(dest.coord(), 0.5, 0.0);

The web-based shader has a built-in fragColor variable where the shader is meant to write the output pixel. To mimick this behavior, we declare an identical variable in the body of the shader:

vec4 fragColor = vec4(0.0);

…and return it in the last line:

return fragColor;

Next, our shader must address a significant difference in the default initialization of loop variables. Take the following GLSL code:

for(float i, z, d, j; …

GLSL automatically initializes the loop-accessible variables i, z, d and j to 0.0. This is not true for the Metal shader. Left as-is, the initial value assigned to these variables is undefined, and no compiler warning is given. Let’s fix this problem by explicitly assigning an initial value to each loop variable:

for(float i=0, z=0, d=0, j=0;

The next statement to require our attention has to do with implicit conversions:

p.xz *= mat2(cos(u_time+p.y * 0.5 + vec4(0, 33, 11, 0))) / max(p.y * 0.1 + 1.0, 0.1);

Here GLSL is able to contruct a mat2 from a four-element vector (not a bad idea for a convenience initializer!) assign the result of the 2x2 matrix multiplication by a 2-element vector back to the X and Z components of the vector p.

To get this working in Metal, we need to unpack those statements. For readability, I went just a little further than necessary:

vec4 m = cos(u_time+p.y * 0.5 + vec4(0, 33, 11, 0));
mat2 n = mat2(m.x, m.y, m.z, m.w);
float v = max(p.y * 0.1 + 1.0, 0.1);
p.xz = p.xz * (n / v);        
  1. The contents of matrix are assigned to the 4-element vector m.
  2. The 2x2 matrix n is explicitly initialized from each of the 4 elements in m.
  3. The single divisor v is assigned to its own float variable.
  4. The X and Z components in p.xz are assigned by explictly multipliying those two components by the result of matrix division, itself a 2-element vector.

That’s it for the source code, but there is one more finishing touch.

Color Space Differences

Most of the shaders you can find online assume to be working with colors encoded in sRGB. Core Image, on the other hand, assumes a linear RGB color space by default. Failure to take this in consideration can lead to unexpected results. FxCore allows you to tell Core Image what color space your kernel outputs in. Change the Output samples option to specify a color space by name:

…and set the newly-available input port to sRGB:

That’s it! The original shader should now be rendering with identical characteristics in FxCore, ready to live a new life as a Core Image kernel.

There are further steps that go into making a shader user-friendly. Users expect to control the location, shape and color of features produced by generators. Knowing how to make certain things parameterizable requires familiarity with what the shader is doing. Among the many resources you can find online, https://mini.gmshaders.com by the author of the shader used in this tutorial is a great place to start.

What if my output seems glitchy?

If your kernel compiles and runs without problems but seems to render with glitches that aren’t visible in the output of the original GLSL shader, it might be due to fastmath optimizations. By default, any Core Image kernel you compile through the Custom Shader node in FxCore is linked against the non-compliant versions of built-in math functions. The solution is to explicitly use the full-precision, compliant variants of these functions found under the metal::precise namespace.

In the example given above, this would involve replacing the tanh() function with metal::precise:tanh or simply precise::tanh.

What about ShaderToy or Twigl?

The above example is based on how FragCoord.xyz sets up its rendering environment. Other websites such as ShaderToy and Twigl use similar conventions, but different names for the same variables. Instead of u_resolution and u_time, shaders found on ShaderToy use iResolution and iTime (with identical semantics). Twigl’s geekier modes are designed for extreme shorthand, using single-letter variables r and t to again represent resolution of the viewport (width and height in pixels) and current time.

It all boils down to understanding which node in FxCore provides that information, and feeding it to the Metal shader as a parameter.