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:
- Read the latest browser and UI values.
- Write those values to uniforms.
- Draw the full-screen triangle.
- 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:
- The store routes, product data, copy, and inquiry path work without the shader.
- The canvas adds material, depth, and interaction when WebGL 2 is available.
- Pointer and touch input change the scene, but keyboard and reduced-motion paths still work.
- Static campaign images remain available when the GPU path fails.
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:
- Keep React in charge of interface state.
- Keep WebGL in charge of pixels.
- Use a full-screen triangle for procedural scenes.
- Send time, pointer, scroll, size, and product state through uniforms.
- Treat reduced motion and no-WebGL fallback as part of the design.
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.