All writing

Learning WebGL through a fragment shader

July 25, 2026 (2d ago)

What I learned from treating the canvas as a programmable surface instead of a place to draw DOM-like objects.

I started learning WebGL properly when I stopped asking it to behave like the DOM.

The browser gives you a <canvas>. WebGL gives that canvas a graphics context. After that, the useful mental model changes. You are not placing divs. You are preparing data for the GPU, compiling shader programs, and asking the GPU to run small programs across vertices and pixels.

The concept that made it click for me was simple: draw one triangle that covers the viewport, then let the fragment shader decide the color of every pixel.

That sounds wasteful until you try it. It is one of the cleanest ways to build atmospheric graphics, fields, procedural materials, and ray-marched scenes in the browser.

Draw less geometry

In a normal UI, you usually think in objects. A button is an object. A card is an object. A product image is an object.

In a shader-heavy WebGL scene, the surface can be much simpler. You can draw one oversized triangle that covers the whole viewport. The vertex shader only needs to place that triangle. The fragment shader receives each pixel position and calculates the final color.

The important input is not "where is my element?" It is "what should this pixel be?"

For a full-screen pass, the vertex data can be tiny:

const vertices = new Float32Array([
  -1, -1,
   3, -1,
  -1,  3,
]);

Those three points create a triangle large enough to cover the clip space. The fragment shader runs for the visible pixels inside the canvas.

This is different from building a scene out of many meshes. It is closer to writing a small image generator that runs every frame.

Move state through uniforms

The shader still needs live input. Time changes. The pointer moves. The canvas resizes. A visitor might switch a product, scroll the page, or ask for reduced motion.

I pass those values as uniforms. A uniform is a value that stays constant for a draw call. The GPU can read it while the shader runs.

For SEREIN, the useful values were:

canvas width, canvas height,
time,
pointer x, pointer y,
scroll progress,
selected fragrance,
motion preference

React owns the product state and the controls. The WebGL code owns the render loop. That split matters. React should not re-render sixty times per second just because the shader needs a new time value.

The loop becomes:

  1. Read the latest browser and UI values.
  2. Write those values to uniforms.
  3. Draw the full-screen triangle.
  4. Let the fragment shader calculate the pixels.

That boundary made the page easier to reason about. React describes the page. WebGL paints the moving material.

Think in coordinates

The fragment shader does not know about your layout. It gets a coordinate.

Usually I normalize the pixel position so the center of the canvas is near zero. Then I correct for aspect ratio so the scene does not stretch on mobile:

vec2 uv = (gl_FragCoord.xy / uResolution.xy) * 2.0 - 1.0;
uv.x *= uResolution.x / uResolution.y;

After that, every visual choice is math against uv, time, and input state.

You can make a gradient from distance to the center. You can make liquid distortion from noise. You can ray march an implicit surface. You can move lighting with pointer input. The canvas is still just a rectangle, but the shader can make that rectangle feel spatial.

This was the first WebGL lesson that felt useful to me: coordinates are the interface.

Ray marching fits this model

The SEREIN bottle uses a ray-marched shape. Ray marching does not start with a mesh. It starts with a function that tells you the distance from a point to the nearest surface.

The shader casts a ray from the camera into the scene. It samples the distance field. If the ray gets close enough to the surface, the shader shades that point. If it travels too far, the pixel becomes background.

The loop is small in shape but expensive in practice:

float t = 0.0;

for (int i = 0; i < MAX_STEPS; i++) {
  vec3 point = rayOrigin + rayDirection * t;
  float distanceToScene = sceneDistance(point);

  if (distanceToScene < HIT_EPSILON) {
    return shade(point);
  }

  t += distanceToScene;

  if (t > FAR_CLIP) {
    break;
  }
}

This is where WebGL becomes less forgiving than CSS animation. Every extra step runs per pixel. A loop that feels small in code can become large on a phone screen.

I had to reduce ray steps, clamp device pixel ratio, pause rendering when the tab is hidden, and provide a non-WebGL fallback. Those choices are not polish. They are part of making the shader usable on real devices.

WebGL is not the experience

The shader can make the page feel alive, but it cannot carry the whole product.

The useful pattern was progressive enhancement:

That constraint kept the WebGL work honest. The shader had a job. It made the product feel tactile. It did not replace navigation, content, accessibility, or checkout logic.

What I would repeat

If I build another WebGL page, I will start with the same split:

That is the part I wish I had understood earlier. WebGL did not become easier when I learned more API calls. It became easier when I found a smaller mental model: one canvas, one shader program, a few uniforms, and a strict boundary between UI state and GPU work.

Sources