Shadertoy to ShaderGif: Porting mainImage() and iTime/iResolution/iMouse

Porting a Shadertoy shader to WebGL or ShaderGif is usually much simpler than it looks. In most cases, you do not need to rewrite the shader logic. You need to wrap mainImage(), provide the uniforms Shadertoy expects, and render a fullscreen pass. This guide shows the exact mapping, a working WebGL setup, the common failure points, and the adjustments that matter when you want a shader to run cleanly outside Shadertoy.

How do you port a Shadertoy shader to WebGL?

The short answer is that you keep the shader’s mainImage(out vec4 fragColor, in vec2 fragCoord) function, then call it from a normal fragment shader main(). After that, you supply Shadertoy-style uniforms such as iResolution, iTime, and iMouse from JavaScript. For a large share of shaders, that is enough to get the first frame rendering.

In practice, the port breaks when the shader depends on features Shadertoy provides automatically, such as texture channels, buffer passes, sound inputs, or WebGL2-only syntax. If your shader only depends on time, resolution, mouse position, and gl_FragCoord, the port is usually straightforward.

What is the exact mapping from Shadertoy to ShaderGif or WebGL?

Shadertoy conceptTypeWhat you provide in WebGLNotes
mainImage(out vec4 fragColor, in vec2 fragCoord)FunctionCall it from fragment main()fragCoord is normally gl_FragCoord.xy
iResolutionvec3vec3(canvasWidth, canvasHeight, 1.0)The third component is pixel aspect ratio and is usually 1.0
iTimefloatElapsed seconds since startUse seconds, not milliseconds
iMouse¨C17C¨C18C current pointer, ¨C19C click originCoordinate origin is bottom-left in shader space
¨C20C¨C21C¨C22CPixel-centre coordinates, not normalised UVs

What wrapper lets mainImage() run in a normal fragment shader?

If the original shader already defines mainImage(), you usually only need a small wrapper. The important detail is to preserve Shadertoy’s expected uniform names, because many shaders reference them directly.

precision highp float; uniform vec3 iResolution; uniform float iTime; uniform vec4 iMouse; // Paste the original Shadertoy shader code below this line. // It should include: // void mainImage(out vec4 fragColor, in vec2 fragCoord) { ... } void main() { mainImage(gl_FragColor, gl_FragCoord.xy); }

This is the core adapter. If your ShaderGif pipeline already renders a fullscreen quad or fullscreen triangle, you may not need anything more on the GLSL side.

What vertex shader do you need for a fullscreen pass?

You do not need model data, camera transforms, or mesh attributes for this kind of shader. A minimal fullscreen pass is enough. Two triangles covering clip space will do the job.

attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); }

What does a complete WebGL setup look like?

The example below compiles the shaders, draws a fullscreen quad, resizes the canvas correctly, and feeds the three uniforms that matter most when porting from Shadertoy.

const canvas = document.getElementById('shader'); const gl = canvas.getContext('webgl', { alpha: false, antialias: false }); if (!gl) { throw new Error('WebGL is not supported in this browser.'); } const vertexSource = ` attribute vec2 a_position; void main() { gl_Position = vec4(a_position, 0.0, 1.0); } `; const fragmentSource = ` precision highp float; uniform vec3 iResolution; uniform float iTime; uniform vec4 iMouse; void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; float wave = 0.5 + 0.5 * sin(iTime + p.x * 8.0); float mouseGlow = 0.0; if (iMouse.x > 0.0 || iMouse.y > 0.0) { vec2 m = iMouse.xy / iResolution.xy; mouseGlow = 0.02 / max(distance(uv, m), 0.001); } vec3 colour = vec3(uv.x, wave, uv.y) + mouseGlow; fragColor = vec4(colour, 1.0); } void main() { mainImage(gl_FragColor, gl_FragCoord.xy); } `; function createShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const log = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(log); } return shader; } function createProgram(gl, vsSource, fsSource) { const vs = createShader(gl, gl.VERTEX_SHADER, vsSource); const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource); const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const log = gl.getProgramInfoLog(program); gl.deleteProgram(program); throw new Error(log); } return program; } const program = createProgram(gl, vertexSource, fragmentSource); const positionLocation = gl.getAttribLocation(program, 'a_position'); const resolutionLocation = gl.getUniformLocation(program, 'iResolution'); const timeLocation = gl.getUniformLocation(program, 'iTime'); const mouseLocation = gl.getUniformLocation(program, 'iMouse'); const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1 ]), gl.STATIC_DRAW ); let mouseX = 0; let mouseY = 0; let clickX = 0; let clickY = 0; let pointerIsDown = false; function resizeCanvas() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.floor(canvas.clientWidth * dpr); const height = Math.floor(canvas.clientHeight * dpr); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } function updatePointer(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; mouseX = (e.clientX - rect.left) * scaleX; mouseY = canvas.height - (e.clientY - rect.top) * scaleY; } canvas.addEventListener('pointermove', (e) => { updatePointer(e); if (pointerIsDown) { clickX = mouseX; clickY = mouseY; } }); canvas.addEventListener('pointerdown', (e) => { pointerIsDown = true; updatePointer(e); clickX = mouseX; clickY = mouseY; }); window.addEventListener('pointerup', () => { pointerIsDown = false; }); function render(now) { const timeInSeconds = now * 0.001; resizeCanvas(); gl.viewport(0, 0, canvas.width, canvas.height); gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); gl.uniform3f(resolutionLocation, canvas.width, canvas.height, 1.0); gl.uniform1f(timeLocation, timeInSeconds); gl.uniform4f(mouseLocation, mouseX, mouseY, clickX, clickY); gl.drawArrays(gl.TRIANGLES, 0, 6); requestAnimationFrame(render); } requestAnimationFrame(render);

What do iResolution, iTime, and iMouse actually mean?

iResolution is not just width and height. In Shadertoy it is a vec3, where xy is the viewport size in pixels and z is pixel aspect ratio. In most browser cases the aspect ratio is square pixels, so z should be 1.0.

iTime is elapsed time in seconds. Many broken ports happen because developers pass milliseconds directly from requestAnimationFrame. If a shader expects seconds and you feed milliseconds, every animation will look wildly sped up or completely unstable.

iMouse is the one most people get wrong. Shader coordinates start at the bottom-left, while DOM events start at the top-left. If the interaction feels inverted, you almost certainly forgot to flip the Y axis when translating pointer positions.

Why does the shader look stretched, upside down, or wrong?

The short answer is that most visual bugs come from coordinate mismatches, not from the shader logic itself. A Shadertoy fragment often assumes pixel coordinates, an aspect-correct screen transform, and a bottom-left origin.

  • Stretched image: you used normalised UVs where the shader expects pixel coordinates.
  • Squashed circles: you divided by iResolution.x instead of using aspect-correct maths such as (fragCoord - 0.5 * iResolution.xy) / iResolution.y.
  • Upside-down mouse: you did not flip the Y axis from DOM space into shader space.
  • Flickering animation: you passed milliseconds into iTime instead of seconds.
  • Nothing renders: the shader depends on texture channels, multi-pass buffers, or WebGL2 features you have not implemented.

Do all Shadertoy shaders work in ShaderGif or plain WebGL?

No. Many do, but not all. Single-pass shaders that only use mainImage(), iTime, iResolution, and iMouse are the easiest to port. Shaders that depend on iChannel0 to iChannel3, buffer passes, keyboard input, audio inputs, or WebGL2-specific syntax require extra work.

In practice, the fastest test is to inspect the original code for references to iChannel, texelFetch, multiple buffer tabs, or GLSL ES 3.00 syntax. If none of those appear, the shader is usually a good candidate for a quick port.

What changes when the target is ShaderGif rather than a live canvas?

When you are exporting frames for a GIF, the shader needs to be deterministic. That means you should prefer a fixed output resolution, a controlled frame rate, and a predictable time step. Interactive mouse input often needs to be removed or hard-coded to a stable value, otherwise every export depends on live pointer state.

In practice, ShaderGif exports are cleaner when you replace free-running iTime with a frame-derived time value such as frameIndex / fps. That gives you reproducible loops and makes it much easier to tune the exact start and end frames for a seamless animation.

Common mistakes when porting mainImage()

  • Declaring iResolution as vec2 instead of vec3.
  • Declaring iMouse as vec2 instead of vec4.
  • Using CSS display size instead of the actual drawing buffer size.
  • Passing requestAnimationFrame time directly without converting to seconds.
  • Forgetting to resize the canvas before updating iResolution.
  • Porting the shader body but forgetting the wrapper main().
  • Assuming a Shadertoy shader is optimised enough for production use.

Operational checklist for a clean Shadertoy-to-WebGL port

  • Keep the original mainImage() function intact where possible.
  • Add a fragment main() that calls mainImage(gl_FragColor, gl_FragCoord.xy).
  • Provide iResolution as vec3(width, height, 1.0).
  • Provide iTime in seconds, not milliseconds.
  • Flip mouse Y coordinates into bottom-left shader space.
  • Resize the actual drawing buffer, not just the CSS box.
  • Check for channels, buffers, and WebGL2-only features before debugging the maths.

FAQ

Can I paste any Shadertoy shader directly into WebGL?

No. Simple single-pass shaders often port quickly, but multi-pass shaders, texture-driven shaders, and WebGL2-only shaders need extra infrastructure.

Why does my port work on desktop but fail on mobile?

Mobile GPUs are less forgiving about precision, loop cost, and heavy raymarching. A shader that runs acceptably on desktop may need lower resolution or simpler maths on mobile.

Why is gl_FragCoord enough for many Shadertoy ports?

Because a large number of Shadertoy shaders generate the entire image procedurally per pixel. They do not need mesh UVs or scene geometry, only the fragment position and a few uniforms.

Do I need WebGL2 for ShaderGif exports?

Not always. Many ShaderGif-friendly shaders work in WebGL1. You only need WebGL2 when the source shader relies on GLSL ES 3.00 features or buffer workflows that require it.

What is the fastest way to debug a broken port?

Start by outputting a simple gradient from gl_FragCoord and verifying iResolution, iTime, and iMouse one by one. Most failures are bad inputs, not bad shader logic.

Final takeaway

Shadertoy to WebGL is mostly an adapter problem, not a rewrite problem. If you preserve mainImage(), feed the right uniform types, and respect Shadertoy’s coordinate conventions, many shaders will run with minimal changes. For ShaderGif, the same rules apply, but fixed timing and deterministic inputs matter more than live interactivity.