WebGL as a 2D API
WebGL renders realtime 3D graphics in the browser, but the API itself is 2D. It deals in exactly two things: clipspace coordinates and colors. The programmer supplies a vertex shader that emits the clipspace coordinates and a fragment shader that emits the color. Clipspace coordinates span -1 to +1 regardless of canvas size.
In the simplest example the vertex shader passes position data straight through, because that data already sits in clipspace. Anything 3D — the conversion from 3D to 2D — has to come from shaders you write. WebGL IS A 2D API.
For 2D work, pixels are more convenient than clipspace. A modified vertex shader can accept rectangles in pixels and convert to clipspace, letting the position data move from clipspace to pixel units.
The rectangle then appears near the bottom of the area, because WebGL treats the bottom-left corner as 0,0. Flipping the y coordinate gives the top-left origin that 2D graphics APIs conventionally use.
Wrapping the rectangle definition in a function makes it reusable at different sizes, and the color can be made settable by having the fragment shader take a color uniform. The resulting code draws 50 rectangles at random positions with random colors.
The API is fairly simple; the extra complexity of 3D comes from more elaborate shaders, not from WebGL itself.
Shader script tag types
<script> tags default to JavaScript: with no type, or with type="javascript" or type="text/javascript", the contents are interpreted as JavaScript. With any other type, the browser ignores the contents — which makes script tags a convenient place to store shaders. You can invent your own type and have your JavaScript inspect it to decide whether to compile the contents as a vertex or fragment shader. In the example, createShaderFromScriptElement looks up a script by id and reads its type to decide which shader to create.
Image processing
Drawing images requires textures. Just as rendering expects clipspace coordinates rather than pixels, reading a texture expects texture coordinates, which run from 0.0 to 1.0 no matter the texture's dimensions. Since only a single rectangle (two triangles) is drawn, each point in the rectangle must map to a position in the texture. That mapping travels from the vertex shader to the fragment shader through a varying — a variable that WebGL interpolates across each pixel as the fragment shader runs.
Building on the previous vertex shader means adding an attribute for the texture coordinates and forwarding it to the fragment shader:
The fragment shader then looks up colors from the texture:
On the JavaScript side, an image must be loaded, a texture created, and the image copied into it. Images load asynchronously in the browser, so the code has to wait for the texture before drawing. A simple manipulation is swapping red and blue in the fragment shader:
Reading neighboring pixels
Because textures are referenced in 0.0-to-1.0 coordinates, one pixel of movement is onePixel = 1.0 / textureSize. A fragment shader can therefore average the left and right neighbors of each pixel, with the texture size passed in from JavaScript.
Once other pixels are addressable, a convolution kernel handles many common effects. A 3x3 kernel is a 3x3 matrix in which each entry scales one of the eight surrounding pixels; the result is divided by the kernel's weight — the sum of its entries — or by 1.0, whichever is greater. The work happens in the shader:
JavaScript supplies the kernel:
a_, u_, and v_ prefixes in GLSL
These are a naming convention only. a_ marks attributes, the data supplied by buffers; u_ marks uniforms, the shader inputs; v_ marks varyings, values passed from the vertex shader to the fragment shader and interpolated between vertices for each pixel drawn.
Combining effects
For multiple effects, one option is generating shaders on the fly: a UI lets the user choose effects, and the code emits a shader performing all of them. That isn't always feasible, though it is often how effects for realtime graphics are built. A more flexible approach uses two textures and renders to each in turn, ping-ponging back and forth while applying the next effect each pass.
This requires framebuffers. Framebuffer is a poor name in WebGL and OpenGL: it is a collection of state, not a buffer of any kind. Attaching a texture to a framebuffer makes that texture a render target. The texture creation code becomes a function:
That function then builds two more textures, each attached to its own framebuffer:
With a set of kernels and a list of them to apply:
each one is applied in sequence, alternating the render target texture:
A few points about the ping-pong pass. Calling gl.bindFramebuffer with null directs rendering to the canvas rather than a framebuffer. WebGL converts clipspace back into pixels according to gl.viewport, which defaults to the canvas size at initialization; because the framebuffers differ in size from the canvas, the viewport must be set appropriately. The Y-coordinate flip used in the fundamentals examples exists because WebGL displays the canvas with 0,0 at the bottom left rather than the conventional 2D top left. Rendering into a framebuffer doesn't need it, since the framebuffer is never displayed and which edge is top or bottom is irrelevant — only that pixel 0,0 in the framebuffer lines up with 0,0 in the calculations. Making the flip an additional shader input handles this:
and it is set at render time:
The example keeps things simple by using a single GLSL program that achieves multiple effects. Full image processing would likely need many programs — one for hue, saturation, and luminance; another for brightness and contrast; others for inverting and for adjusting levels — along with code to switch programs and update each program's parameters. That refactoring, and the risk of it turning into a mess of spaghetti, is left to the reader.
Alpha and the WebGL backbuffer
Developers coming from OpenGL often hit surprises with how WebGL handles alpha in the canvas, and the root cause is compositing. An OpenGL backbuffer is effectively not composited by the window manager, so its alpha value is irrelevant. A WebGL canvas is composited by the browser into the page, and the default is pre-multiplied alpha — the same convention used by transparent <img> tags for .png files and by 2d canvas tags.
There are several ways to bring WebGL closer to OpenGL behavior here.
1. Request non-premultiplied compositing
const gl = canvas.getContext('webgl', {premultipliedAlpha: false});
The default is true. Even so, the canvas is still composited over whatever background sits beneath it — the canvas's background color, its container's, the page's, or content behind a canvas with z-index > 0. On a related note, the browser's CSS context menu item is still available on canvas elements in Chrome and Firefox unless you prevent it. A quick diagnostic: give the canvas a bright background such as red. Any alpha problem becomes visible immediately.
canvas.style.background = 'red';
Setting that background to black instead will conceal alpha issues rather than reveal them.
2. Declare that the backbuffer has no alpha
const gl = canvas.getContext('webgl', {alpha: false});
The backbuffer then carries only RGB, which is closer to OpenGL. A browser that knows there is no alpha can optimize how the canvas is composited, which is a strong argument for this option. The trade-off is that alpha genuinely is unavailable in the backbuffer, so any technique that depends on it will not work. Few applications actually rely on backbuffer alpha, and arguably alpha: false should have been the default.
3. Clear alpha once rendering is done
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
Clearing is generally very fast because most hardware special-cases it, which makes this a cheap fix at the end of each frame. Libraries could reasonably default to this behavior while letting the minority of developers who use alpha for compositing effects opt in, giving everyone else the best performance with the fewest surprises.
4. Clear alpha once, then stop writing to it
gl.colorMask(true, true, true, false);
Clearing the alpha channel a single time and leaving color writes to RGB only avoids the per-frame cost entirely. When rendering into your own framebuffers you may need rendering to alpha back on, then off again once you switch back to the canvas.
Handling textures and blend equations
PNG files with alpha are uploaded to textures with their alpha pre-multiplied by default, which is generally not how games store their assets. To stop that behavior:
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
Most OpenGL code written in the field uses a blend equation that assumes non-premultiplied textures:
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
For pre-multiplied textures, the appropriate setup is:
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
Those are the known remedies; other approaches exist and are worth sharing if you have one.



