In this post, I'll explain how to port shaders made with and for Shadertoy to Three.js.
Shadertoy is an online community and tool for creating and sharing shaders through WebGL, used for both learning and teaching 3D computer graphics in a web browser. Crucially, it's also where I often find inspiration for amazing shaders — being left in awe with the amount of creativity people have. Turning math into pretty images is a skill I admire so much.
I'm more of a web developer myself - and I sometimes find myself trying to re-use parts of Shadertoy shaders in Three.js scenes in web apps (only where allowed and with proper attribution, of course). Porting Shadertoy shaders over to Three.js is surprisingly straightforward, but it took me a while to figure it out - none of the popular LLMs knew about it, neither could I find anything with Google. If you're trying to do the same thing, let me tell you how.
Scene Setup
There are dozens of ways to setup a Three.js scene - and which one you choose doesn't matter for the steps below. Below, I'm using TypeScript, React, and react-three-fiber, but don't worry about those choices if you made different ones. The way you'll interact with the shaders below remains the same and the changes I'm making should easily translate to other frameworks and libraries.
In short, I'm setting up a scene, an orthographic camera, a simple plane mesh, and a shader material. <ShaderMaterial />
is a custom component that we'll cover in the next step. For now, let's call out the only weird thing: We're setting a new key
on the <Canvas />
every time the window size changes. That's necessary because shaders tend to break if we try to change the resolution on them dynamically, so we'll likely have to reset the entire renderer. You'll also have to do this whenever you want to swap out the shader dynamically. Whenever your Canvas goes black after some kind of interaction, simply make sure you update your key
- or whatever the equivalent is in your framework or web app.
If you're curious about the useWindowSize()
hook, here's the one I tend to use:
ShaderMaterial
Below, I'm setting up a Three.js ShaderMaterial and making important additions to enable Shadertoy shaders.
First, we're importing two shaders: A FragmentShader
and a VertexShader
. We'll cover the definitions of both later. Let's cover everything noteworthy here:
Mouse Interaction
Mouse interactions: Shadertoy uses a "pixels from the bottom left" coordinate system, so we'll have to re-calculate mouse events. You'll notice that I'm multiplying values by window.devicePixelRatio
- this ensures that your shader is dealing with the correct resolution.
Uniforms
Uniforms: A uniform is a "global shader variable". We'll have to inject the variables that are always available in Shadertoy. Below, I'm covering the most popular ones (iTime
, iTimeDelta
, iResolution
, iMouse
). The flow is always the same: We first declare the uniform and its type in our Three.js ShaderMaterial and will then also declare it later in our FragmentShader
.
All uniforms available are below. Search your shader for these values, and if they exist, add them following the same pattern I'm using below.
You'll also notice that I'm manually updating the uniforms on each frame.
FragmentShader
This is where we'll actually "port" the Shadertoy shader. Let's take this animated Windows 10 "wallpaper" as an example. Credits go to gyabo (and, I assume, Microsoft).
The code for that shader is fairly simple:
#define PI 3.1415926535897921284
#define REP 25
#define d2r(x) (x * PI / 180.0)
#define WBCOL (vec3(0.5, 0.7, 1.7))
#define WBCOL2 (vec3(0.15, 0.8, 1.7))
float hash( vec2 p ) {
float h = dot( p, vec2( 127.1, 311.7 ) );
return fract( sin( h ) * 458.325421) * 2.0 - 1.0;
}
float noise( vec2 p ) {
vec2 i = floor( p );
vec2 f = fract( p );
f = f * f * ( 3.0 - 2.0 * f );
return mix(
mix( hash( i + vec2( 0.0, 0.0 ) ), hash( i + vec2( 1.0, 0.0 ) ), f.x ),
mix( hash( i + vec2( 0.0, 1.0 ) ), hash( i + vec2( 1.0, 1.0 ) ), f.x ),
f.y
);
}
vec2 rot(vec2 p, float a) {
return vec2(
p.x * cos(a) - p.y * sin(a),
p.x * sin(a) + p.y * cos(a));
}
float nac(vec3 p, vec2 F, vec3 o) {
const float R = 0.0001;
p += o;
return length(max(abs(p.xy)-vec2(F),0.0)) - R;
}
float by(vec3 p, float F, vec3 o) {
const float R = 0.0001;
p += o;
return length(max(abs(mod(p.xy, 3.0))-F,0.0)) - R;
}
float recta(vec3 p, vec3 F, vec3 o) {
const float R = 0.0001;
p += o;
return length(max(abs(p)-F,0.0)) - R;
}
float map1(vec3 p, float scale) {
float G = 0.50;
float F = 0.50 * scale;
float t = nac(p, vec2(F,F), vec3( G, G, 0.0));
t = min(t, nac(p, vec2(F,F), vec3( G, -G, 0.0)));
t = min(t, nac(p, vec2(F,F), vec3(-G, G, 0.0)));
t = min(t, nac(p, vec2(F,F), vec3(-G, -G, 0.0)));
return t;
}
float map2(vec3 p) {
float t = map1(p, 0.9);
//t = max(t, recta(p, vec3(1.0, 1.0, 0.02), vec3(0.0, 0.0, 0.0)));
t = max(t, recta(p, vec3(1.0, 1.0, 0.02), vec3(0.0, 0.0, 0.0)));
return t;
}
// http://glslsandbox.com/e#26840.0
float gennoise(vec2 p) {
float d = 0.5;
mat2 h = mat2( 1.6, 1.2, -1.2, 1.6 );
float color = 0.0;
for( int i = 0; i < 2; i++ ) {
color += d * noise( p * 5.0 + iTime);
p *= h;
d /= 2.0;
}
return color;
}
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
fragColor = vec4(0.0);
for(int count = 0 ; count < 2; count++) {
vec2 uv = -1.0 + 2.0 * ( fragCoord.xy / iResolution.xy );
uv *= 1.4;
uv.x += hash(uv.xy + iTime + float(count)) / 512.0;
uv.y += hash(uv.yx + iTime + float(count)) / 512.0;
vec3 dir = normalize(vec3(uv * vec2(iResolution.x / iResolution.y, 1.0), 1.0 + sin(iTime) * 0.01));
dir.xz = rot(dir.xz, d2r(70.0));
dir.xy = rot(dir.xy, d2r(90.0));
vec3 pos = vec3(-0.1 + sin(iTime * 0.3) * 0.1, 2.0 + cos(iTime * 0.4) * 0.1, -3.5);
vec3 col = vec3(0.0);
float t = 0.0;
float M = 1.002;
float bsh = 0.01;
float dens = 0.0;
for(int i = 0 ; i < REP * 24; i++) {
float temp = map1(pos + dir * t, 0.6);
if(temp < 0.2) {
col += WBCOL * 0.005 * dens;
}
t += bsh * M;
bsh *= M;
dens += 0.025;
}
//windows
t = 0.0;
float y = 0.0;
//for(int i = 0 ; i < REP * 50; i++)
for(int i = 0 ; i < REP; i++)
{
float temp = map2(pos + dir * t);
if(temp < 0.1) {
//col += WBCOL2 * 0.005;
col += WBCOL2 * 0.5;
}
t += temp;
y++;
}
col += ((2.0 + uv.x) * WBCOL2) + (y / (25.0 * 50.0));
col += gennoise(dir.xz) * 0.5;
col *= 1.0 - uv.y * 0.5;
col *= vec3(0.05);
col = pow(col, vec3(0.717));
fragColor += vec4(col, 1.0 / (t));
}
fragColor /= vec4(2.0);
}
To port it over, we'll need to do the following things:
Add our uniforms
At the top, add our uniforms:
uniform float iTime;
uniform float iTimeDelta;
uniform vec2 iResolution;
uniform vec3 iMouse;
// ...rest of the shader
Add a main() function
Then, at the bottom, add a main()
function that calls the default Shadertoy mainImage()
function:
// ...rest of the shader
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
VertexShader
The VertexShader doesn't have to do a lot of work, we do most of it in the FragmentShader. This default one should work for almost all ShaderToy shaders:
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Tweaking Things
For most shaders, the work done above should be enough. However, some shaders might require a bit of surgery to ensure correct colors. When you run the example above for instance, you'll discover that you see bright shapes but not at all what you expected:
The problem: Transparency. Not all shaders write fully opaque pixels, some of them are transparent. Whenever they are transparent, things tend to look bad. To fix this, we'll need to perform surgery on the shader itself and set the "alpha channel" of each pixel to 1. This requires you to understand a bit of the WebGL basics, so bear with me: The mainImage()
function in your FragmentShader is given a pixel coordinate and is supposed to change its pixel color. You can see that in the function definition: void mainImage( out vec4 fragColor, in vec2 fragCoord )
. At the very bottom of the function, you'll set the "alpha channel" to 1.0
by calling fragColor.a = 1.0;
In our example above, I'm adding it here:
// ...rest of the shader
void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
// ... rest of the function
fragColor.a = 1.0;
}
void main() {
mainImage(gl_FragColor, gl_FragCoord.xy);
}
And then, finally, we have a working Shadertoy shader in Three.js:
Find the code on GitHub
You can find the demo code for this tutorial on GitHub - together with a deployed live demo.