In this instalment we will convert a ShaderToy shader to TSL. I decided to use the classic fractal, the Mandelbrot. Here is my starting point, created by Inigo Quilez.
Here is an outline of the ShaderToy code. ShaderToy shaders have a mainImage function that receives a vec2 fragCoord parameter. Thus is the viewport pixel position of the pixel being rendered. They use WebGL 2.0, which is based on OpenGL ES 3.0, which uses an in out specifier for parameters. We’re going to use the GLSL to TSL transpiler. So first we need to do some refactoring. The transpiler does not understand pre-compile directives like #define and for the mainImage function instead of using out
we need to return a color value. I decided the easiest solution was to get a working GLSL version.
#define AA 2
float mandelbrot( in vec2 c )
{
...
return mix( n, sn, al );
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
...
float l = mandelbrot(c);
...
fragColor = vec4( col, 1.0 );
}
The shader I created is applied to a square plane whereas the ShaderToy version is applied to the entire viewport. I could have approached it as a post process applied to the entire viewport, but that seemed to complicate the problem. Instead when we create the TSL version we will be creating a function to apply to the fragmentNode. Using the TSL function uv(). We will receive an interpolated uv value based on the pixel’s position within the triangle being rendered.
const vertexShader = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`
For the fragment shader I added a few uniforms, uContrast, uBrightness, uFrequency and uPhase, mainly so the viewer can adjust the colors. We also need a time value, uTime, giving elapsed time in seconds and the number of iterations, uCount.
const fragmentShader = `
varying vec2 vUv;
uniform float uTime;
uniform float uContrast;
uniform float uBrightness;
uniform float uFrequency;
uniform vec3 uPhase;
uniform int uCount;
...
The mandelbrot function needs very few changes. I removed the pre-processor directives #if … #endif.
The Mandelbrot set is defined in the complex plane as the complex numbers c for which the function
does not diverge to infinity when iterated starting at z = 0 but remains bounded by ån absolute value.
Line 16 calculates the next iteration. Complex numbers have two components the real and the imaginary. For this code the complex number is represented as a vec2. When multiplying a complex number we use this definition
For z squared. a=z.x, b=z.y, c=z.x and d=z.y.
ac = z.x * z.x, bc = z.y * z.y therefore the x component of z squared is z.x*z.x – z.y*z.y.
ad = z.x * z.y, bc = z.y * z.x therefore the y component of z squared is 2.0*z.x*z.y.
Line 17 gives the escape condition. The usual test is if the magnitude of z exceeds 2. But it is more efficient to test the square magnitude. For a complex number the dot product gives the squared magnitude. To be a little more precise we set the boundary condition as the squared magnitude exceeds the squared iteration count value. This allows n to take any value between 0 and uCount – 1. n is used to color the pixel.
...
float mandelbrot( vec2 c )
{
float c2 = dot(c, c);
// skip computation inside M1 - https://iquilezles.org/articles/mset1bulb
if( 256.0*c2*c2 - 96.0*c2 + 32.0*c.x - 3.0 < 0.0 ) return 0.0;
// skip computation inside M2 - https://iquilezles.org/articles/mset2bulb
if( 16.0*(c2+2.0*c.x+1.0) - 1.0 < 0.0 ) return 0.0;
float B = float(uCount * uCount);
float n = 0.0;
vec2 z = vec2(0.0);
for( int i=0; i<uCount; i++ )
{
z = vec2( z.x*z.x - z.y*z.y, 2.0*z.x*z.y ) + c;
if( dot(z,z)>B ) break;
n += 1.0;
}
if( n>float(uCount - 1) ) return 0.0;
// ------------------------------------------------------
// optimized smooth interation count
float sn = n - log2(log2(dot(z,z))) + 4.0;
return sn;
}
...
Now the adapted mainImage function. I’m using the WebGL 1.0 syntax, where we set gl_FragColor. Inigo uses a clever bit of manipulation to zoom and rotate. We have nested loops to have a bit of anti-aliasing. At line 11 where previously the code used the parameter fragCoord now we use the varying vUv. Time is a uniform being updated in the render function. We then rotate the updated uv position, p at line 20. A little more adjusting to change the rotation center and we have the value c to use in the mandelbrot function.
void main( )
{
vec3 col = vec3(0.0);
int AA = 2;
float AAsq = float(AA*AA);
for( int m=0; m<AA; m++ ){
for( int n=0; n<AA; n++ )
{
vec2 p = (-1.0 + 2.0*(vUv+vec2(float(m) * 0.001,float(n) * 0.001)/float(AA)));
float w = float(AA*m+n);
float time = uTime + 0.5*(1.0/24.0)*w/AAsq;
float zoo = 0.72 + 0.28*cos(.09*time);
float theta = 0.05*(1.0-zoo)*time;
float coa = cos( theta );
float sia = sin( theta );
zoo = pow( zoo, 8.0);
vec2 xy = vec2( p.x*coa-p.y*sia, p.x*sia+p.y*coa);
//vec2 c = vec2(-.745,.186) + xy*zoo;
vec2 c = vec2(-0.720,-0.26) + xy*zoo;
float l = mandelbrot(c);
//color(t) = a + b ⋅ cos[ 2π(c⋅t+d)]
//a: contrast
//b: brightness
//c: frequency
//d: phase
col += (l<0.5) ? vec3(0.0,0.0,0.0) :
uContrast + uBrightness*cos( 6.28*(uFrequency*(l/float(uCount)) + uPhase) );
}
}
col /= AAsq;
gl_FragColor = vec4( col, 1.0 );
}
`;
The CodePen below gives an example of this in action.
The CodePen below gives the transpiler result.
The imports we need are these …
import { uniform, uv, vec2, dot, Break, float, mul, If, int, Loop, log2, Fn, vec3, time, cos, add, sub, sin, pow, select, vec4 } from 'three/tsl';
In the tsl function the uniforms needed are shown below. These are used by the gui.
const uContrast = uniform( float(0.5) );
const uBrightness = uniform( float(0.402) );
const uFrequency = uniform( float(4.1) );
const uPhase = uniform( vec3() );
const uCount = uniform( int(100) );
The mandelbrot function is taken directly from the transpiler output.
const mandelbrot = /*#__PURE__*/ Fn( ( [ c_immutable ] ) => {
const c = vec2( c_immutable ).toVar();
const c2 = float( dot( c, c ) ).toVar();
If( mul( 256.0, c2 ).mul( c2 ).sub( mul( 96.0, c2 ) ).add( mul( 32.0, c.x ).sub( 3.0 ) ).lessThan( 0.0 ), () => {
return 0.0;
} );
If( mul( 16.0, c2.add( mul( 2.0, c.x ) ).add( 1.0 ) ).sub( 1.0 ).lessThan( 0.0 ), () => {
return 0.0;
} );
const B = float( uCount ).toVar();
const n = float( 0.0 ).toVar();
const z = vec2( 0.0 ).toVar();
Loop( { start: int( 0 ), end: uCount }, ( { i } ) => {
z.assign( vec2( z.x.mul( z.x ).sub( z.y.mul( z.y ) ), mul( 2.0, z.x ).mul( z.y ) ).add( c ) );
If( dot( z, z ).greaterThan( B.mul( B ) ), () => {
Break();
} );
n.addAssign( 1.0 );
} );
If( n.greaterThan( float( uCount.sub( int( 1 ) ) ) ), () => {
return 0.0;
} );
const sn = float( n.sub( log2( log2( dot( z, z ) ) ) ).add( 4.0 ) ).toVar();
return sn;
} ).setLayout( {
name: 'mandelbrot',
type: 'float',
inputs: [
{ name: 'c', type: 'vec2' }
]
} );
The fragment shader, fragTSL, is just the main function from the transpiler.
const fragTSL = /*#__PURE__*/ Fn( () => {
const col = vec3( 0.0 ).toVar();
const AA = int( int( 2 ) ).toVar();
const AAsq = float( float( AA ).mul( float( AA ) ) ).toVar();
const vUv = uv().toVar();
Loop( { end: AA, name: 'm' }, ( { m } ) => {
Loop( { end: AA, name: 'n' }, ( { n } ) => {
const p = vec2( float( - 1.0 ).add( mul( 2.0, vUv.add( vec2( float( m ).mul( 0.001 ), float( n ).mul( 0.001 ) ).div( float( AA ) ) ) ) ) ).toVar();
const w = float( AA.mul( m ).add( n ) ).toVar();
const tm = float( time.add( mul( 0.5 * 1.0 / 24.0, w ).div( AAsq ) ) ).toVar();
const zoo = float( add( 0.72, mul( 0.28, cos( mul( .09, tm ) ) ) ) ).toVar();
const theta = float( mul( 0.05, sub( 1.0, zoo ) ).mul( tm ) ).toVar();
const coa = float( cos( theta ) ).toVar();
const sia = float( sin( theta ) ).toVar();
zoo.assign( pow( zoo, 8.0 ) );
const xy = vec2( p.x.mul( coa ).sub( p.y.mul( sia ) ), p.x.mul( sia ).add( p.y.mul( coa ) ) ).toVar();
const c = vec2( vec2( float( - 0.720 ), float( - 0.26 ) ).add( xy.mul( zoo ) ) ).toVar();
const l = float( mandelbrot( c ) ).toVar();
col.addAssign( select( l.lessThan( 0.5 ), vec3( 0.0, 0.0, 0.0 ), uContrast.add( uBrightness.mul( cos( mul( 6.28, uFrequency.mul( l.div( float( uCount ) ) ).add( uPhase ) ) ) ) ) ) );
} );
} );
col.divAssign( AAsq );
return vec4( col, 1.0 );
//return vec4( vUv, 0, 1);
} );
material.fragmentNode = fragTSL();
And finally the gui which allows the viewer to adjust the colors.
const options = {
phase: 0x978672
}
const color = new THREE.Color(options.phase);
uPhase.value.set( color.r, color.g, color.b );
const gui = new GUI();
gui.add( uCount, 'value', 10, 512, 1 ).name( 'count' );
gui.add( uContrast, 'value', 0, 2 ).name( 'contrast' );
gui.add( uBrightness, 'value', 0, 2 ).name( 'brightness' );
gui.add( uFrequency, 'value', 0, 10).name( 'frequency' );
gui.addColor( options, 'phase' ).onChange( value => {
color.setHex( value );
uPhase.value.set( color.r, color.g, color.b );
} )
Here is the final result.
Using the ThreeJS Transpiler is a great technique for converting ShaderToy shaders to the ThreeJS platform and helps improve your TSL skills. Always make sure you have the permission of the original author when doing this.
If you find these articles useful, perhaps you can buy me a coffee by clicking the button below. It might encourage me to write more.
Signup to my mailing list.