Diving into Shaders: WebGL, GLSL, and My First Trail Effect

Diving into Shaders: WebGL, GLSL, and My First Trail Effect

17/09/2026

|

Projects

Experiments

I’ve wanted to properly learn shaders for a while, mostly out of frustration with how much of the interesting visual stuff on the web (the distortion, the little reactive touches you see on portfolio sites) was a black box to me. I went looking for something like Rustlings or Codecademy for GLSL, an interactive, build-it-as-you-go tutorial, and didn’t really find one. What I found instead was The Book of Shaders and Shadertoy, which turned out to be the right combination anyway: one teaches you the fragment shader mindset, the other is an endless pile of other people’s code to pull apart.

The stack

For actually wiring shaders into my SvelteKit projects, I skipped Three.js. It’s the obvious choice for most people doing anything 3D on the web, but it felt like a lot of overhead for what I wanted, which was closer to the Shadertoy model: a fullscreen quad, a fragment shader, and uniforms like time and resolution. I went with OGL instead, a much lighter WebGL library that gets out of the way without making me hand-roll buffer and shader compilation boilerplate myself.

The actual learning has mostly been about building an intuition for a fairly small set of maths primitives, length, smoothstep, mix, dot, and getting used to the idea that shaders are just maths on coordinates. Once that clicks, most effects stop feeling like magic and start feeling like “what if I nudged the input before sampling it.”

Below is an example of the primitives in this experiments final form:

const vertex = /* glsl */ `
    attribute vec2 uv;
    attribute vec2 position;

    varying vec2 vUv;

    void main() {
        vUv = uv;
        gl_Position = vec4(position, 0, 1);
    }
`;

const fragment = /* glsl */ `
    precision highp float;

    uniform float uTime;
    uniform vec3 uColour;
    uniform vec2 uMouse;
    uniform float uAspect;
    uniform sampler2D uPrevious;

    varying vec2 vUv;

    void main () {
        float dist = length(vec2((vUv.x - uMouse.x) * uAspect, vUv.y - uMouse.y));
        float blob = smoothstep(0.1, 0.0, dist);
        vec3 trail = texture2D(uPrevious,vUv).rgb * 0.95;
        gl_FragColor.rgb = max(vec3(1.0, 0.4, 0.1) * blob, trail);
        gl_FragColor.a = 1.0;
    }
`;

Where things stand

Started by pulling up OGL’s own example and reading through it properly rather than skimming, a fullscreen triangle, a basic fragment shader, the usual setup with time and resolution uniforms wired in. Getting that running in a Svelte component meant converting the raw document.querySelector pattern over to bind:this, and adding proper cleanup so it doesn’t leak event listeners or leave requestAnimationFrame running after the component unmounts.

From there I built a mouse-reactive trail effect from scratch: move your cursor and it leaves a fading, glowing trail behind it. First was getting pointer position into the shader properly, normalising against screen width and height and flipping the Y axis since screen space and shader UV space disagree on which way is up. Then a single-pass version, a glowing blob under the cursor using distance and a soft circular falloff, with an aspect ratio correction so it doesn’t stretch on non-square viewports.

<svelte:window onmousemove={updatePosition} bind:innerWidth={screen.w} bind:innerHeight={screen.h} />

let mousePos: {
    x: number;
    y: number;
} = $state({
    x: 0.5,
    y: 0.5
});

let screen: {
    w: number;
    h: number;
} = $state({
    w: 0,
    h: 0
});

function updatePosition(e: MouseEvent) {
  mousePos.x = e.clientX / screen.w;
  mousePos.y = 1 - e.clientY / screen.h;
}

The trail itself needed something the blob alone couldn’t do: memory of previous frames. That meant learning the ping-pong render target pattern, two render targets swapped each frame, where the current frame samples the previous one’s texture, decays it slightly, and draws a fresh blob on top before it gets written out. It’s the first thing I’ve built where the shader has memory across frames rather than just reacting to the current instant, and that felt like a proper step up in understanding. With this I was able to set up two render buffers to manage the reading and writing within the program along with a second fragment shader used for just reading from the previous frame

const targetFragment = /* glsl */ `
    precision highp float;

    uniform sampler2D uTrail;

    varying vec2 vUv;

    void main() {
        gl_FragColor.rgb = texture2D(uTrail, vUv).rgb;
        gl_FragColor.a = 1.0;
    }
`;

const bufferA = new RenderTarget(gl, { width: screen.w, height: screen.h });
const bufferB = new RenderTarget(gl, { width: screen.w, height: screen.h });

let reading = bufferB;
let writing = bufferA;

Still a rough experiment rather than anything shipped, but that’s the point of this space. More to come as I keep pulling shaders apart. Below is the full code for the experiment, as well as being able to be found here: https://github.com/liamlyness97/shader-learning

<script lang="ts">
	import { Color, Mesh, Program, Renderer, Triangle, Vec2, RenderTarget } from 'ogl';
	import { onMount } from 'svelte';

	let wrapper: HTMLDivElement;

	let mousePos: {
		x: number;
		y: number;
	} = $state({
		x: 0.5,
		y: 0.5
	});

	let screen: {
		w: number;
		h: number;
	} = $state({
		w: 0,
		h: 0
	});

	function updatePosition(e: MouseEvent) {
		mousePos.x = e.clientX / screen.w;
		mousePos.y = 1 - e.clientY / screen.h;
	}

	const vertex = /* glsl */ `
        attribute vec2 uv;
        attribute vec2 position;

        varying vec2 vUv;

        void main() {
            vUv = uv;
            gl_Position = vec4(position, 0, 1);
        }
    `;

	const fragment = /* glsl */ `
        precision highp float;

        uniform float uTime;
        uniform vec3 uColour;
        uniform vec2 uMouse;
        uniform float uAspect;
		uniform sampler2D uPrevious;

        varying vec2 vUv;

        void main () {
            float dist = length(vec2((vUv.x - uMouse.x) * uAspect, vUv.y - uMouse.y));
            float blob = smoothstep(0.1, 0.0, dist);
			vec3 trail = texture2D(uPrevious,vUv).rgb * 0.95;
            gl_FragColor.rgb = max(vec3(1.0, 0.4, 0.1) * blob, trail);
            gl_FragColor.a = 1.0;
        }
    `;

	const targetFragment = /* glsl */ `
		precision highp float;

		uniform sampler2D uTrail;

		varying vec2 vUv;

		void main() {
			gl_FragColor.rgb = texture2D(uTrail, vUv).rgb;
			gl_FragColor.a = 1.0;
		}
	`;

	onMount(() => {
		const renderer = new Renderer();
		const gl = renderer.gl;

		wrapper.appendChild(gl.canvas);

		gl.clearColor(1, 1, 1, 1);

		const geometry = new Triangle(gl);

		const bufferA = new RenderTarget(gl, { width: screen.w, height: screen.h });
		const bufferB = new RenderTarget(gl, { width: screen.w, height: screen.h });

		let reading = bufferB;
		let writing = bufferA;

		const program = new Program(gl, {
			vertex,
			fragment,
			uniforms: {
				uTime: { value: 0 },
				uColour: { value: new Color(0.3, 0.2, 0.5) },
				uMouse: { value: new Vec2(mousePos.x, mousePos.y) },
				uAspect: { value: screen.w / screen.h },
				uPrevious: { value: bufferB }
			}
		});

		const targetProgram = new Program(gl, {
			vertex,
			fragment: targetFragment,
			uniforms: {
				uTrail: { value: writing.texture }
			}
		});

		function resize() {
			renderer.setSize(screen.w, screen.h);
			program.uniforms.uAspect.value = screen.w / screen.h;
		}

		window.addEventListener('resize', resize, false);
		resize();

		const mesh = new Mesh(gl, { geometry, program });
		const targetMesh = new Mesh(gl, { geometry, program: targetProgram });

		requestAnimationFrame(update);
		function update(t) {
			requestAnimationFrame(update);

			program.uniforms.uTime.value = t * 0.001;
			program.uniforms.uMouse.value.set(mousePos.x, mousePos.y);
			program.uniforms.uPrevious.value = reading.texture;

			targetProgram.uniforms.uTrail.value = writing.texture;

			renderer.render({ scene: mesh, target: writing });

			[reading, writing] = [writing, reading];

			renderer.render({ scene: targetMesh });
		}
	});
</script>

<svelte:window
	onmousemove={updatePosition}
	bind:innerWidth={screen.w}
	bind:innerHeight={screen.h}
/>

<div bind:this={wrapper} class="h-screen w-full"></div>