Your First WebGL Triangle and Render Loop

Updated June 2026
Drawing a triangle is the "hello world" of GPU programming. Every 3D game, no matter how complex, is built from triangles processed through the same pipeline you will set up here. This guide walks through each step of creating a WebGL context, compiling shaders, uploading vertex data, and rendering a triangle inside a continuous render loop.

Before writing any WebGL code, it helps to understand what you are building toward. A WebGL application requires five things to produce a single frame: a canvas with a WebGL context, a compiled shader program (vertex + fragment), vertex data in a GPU buffer, vertex attribute configuration telling the GPU how to read that buffer, and a draw call that executes the pipeline. Once you can draw a static triangle, adding a render loop with requestAnimationFrame turns it into a game-ready animation framework.

Step 1: Create the Canvas and Get the WebGL Context

Start with a standard HTML canvas element. The canvas is the drawable surface where WebGL renders its output. Set the width and height attributes to control the resolution of the rendering buffer (not the display size, which is controlled by CSS).

In JavaScript, get a reference to the canvas and request a WebGL 2 context by calling canvas.getContext('webgl2'). If WebGL 2 is not available, fall back to canvas.getContext('webgl'). The returned object is your rendering context, and every subsequent WebGL call goes through it. If getContext returns null, the browser or device does not support WebGL.

Set the viewport to match the canvas dimensions with gl.viewport(0, 0, canvas.width, canvas.height). This tells WebGL which region of the canvas to render into. Set a clear color with gl.clearColor(0.0, 0.0, 0.0, 1.0) for a black background, and call gl.clear(gl.COLOR_BUFFER_BIT) to fill the canvas with that color. At this point you have a black rectangle, which confirms the WebGL context is working.

Step 2: Write and Compile Vertex and Fragment Shaders

WebGL requires two shader programs written in GLSL (OpenGL Shading Language). The vertex shader runs once per vertex and transforms vertex positions from model space to clip space. The fragment shader runs once per pixel covered by a primitive and computes the final color of each pixel.

For a basic triangle, the vertex shader receives a 2D position attribute and passes it through as the clip-space position. In GLSL ES 3.00 (WebGL 2), this looks like: declare an in vec2 aPosition attribute, then set gl_Position = vec4(aPosition, 0.0, 1.0) in the main function. The z component is 0.0 (flat on the screen) and w is 1.0 (no perspective division).

The fragment shader simply outputs a solid color. Declare an out vec4 fragColor and set it to a value like vec4(0.2, 0.7, 1.0, 1.0) for a light blue.

To compile these shaders, create shader objects with gl.createShader(gl.VERTEX_SHADER) and gl.createShader(gl.FRAGMENT_SHADER), attach the source code with gl.shaderSource(), and compile each with gl.compileShader(). Check for compilation errors using gl.getShaderParameter(shader, gl.COMPILE_STATUS) and gl.getShaderInfoLog(). Shader compilation errors are the most common early mistake, usually caused by GLSL syntax errors or version mismatches.

Link the compiled shaders into a program with gl.createProgram(), gl.attachShader() for each, and gl.linkProgram(). Check link status the same way. The linked program is what you activate with gl.useProgram() before drawing.

Step 3: Create Vertex Buffers and Upload Geometry

A triangle is defined by three vertices. In clip space, coordinates range from -1.0 to 1.0 on both axes, with (0,0) at the center of the canvas. Define three positions as a Float32Array: for example, (-0.5, -0.5) for the bottom-left vertex, (0.5, -0.5) for bottom-right, and (0.0, 0.5) for the top center.

Create a GPU buffer with gl.createBuffer(), bind it to the ARRAY_BUFFER target with gl.bindBuffer(gl.ARRAY_BUFFER, buffer), and upload the data with gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW). The STATIC_DRAW hint tells the GPU that this data will not change frequently, allowing it to place the buffer in fast memory. For dynamic geometry that changes every frame, use DYNAMIC_DRAW.

At this point the vertex positions exist in GPU memory, but WebGL does not yet know how to interpret the data. The buffer is just a blob of bytes until you configure vertex attributes.

Step 4: Configure Vertex Attributes

Vertex attributes connect your buffer data to your shader's input variables. In WebGL 2, create a Vertex Array Object (VAO) with gl.createVertexArray() and bind it with gl.bindVertexArray(vao). The VAO stores all vertex attribute configuration so you can restore it with a single bind call later.

Look up the attribute location in your shader program with gl.getAttribLocation(program, 'aPosition'). Enable it with gl.enableVertexAttribArray(location). Then configure how WebGL reads the buffer with gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 0, 0). The arguments specify: 2 components per vertex (x, y), data type is FLOAT, no normalization, stride of 0 (tightly packed), and offset of 0 (start at the beginning of the buffer).

The vertex attribute pointer is one of the most error-prone parts of WebGL setup. If the component count, data type, stride, or offset do not match the actual buffer layout, you will get garbage geometry or no rendering at all, with no error message. Double-check that your Float32Array layout matches your vertexAttribPointer parameters.

Step 5: Draw the Triangle

With the shader program linked, the buffer uploaded, and the vertex attributes configured, you can now draw. Clear the canvas with gl.clear(gl.COLOR_BUFFER_BIT), activate your shader program with gl.useProgram(program), bind the VAO with gl.bindVertexArray(vao), and issue the draw call with gl.drawArrays(gl.TRIANGLES, 0, 3).

The arguments to drawArrays specify the primitive type (TRIANGLES), the starting vertex index (0), and the vertex count (3). WebGL reads three vertices from the buffer, runs each through the vertex shader, rasterizes the resulting triangle, runs the fragment shader for every pixel inside it, and writes the results to the canvas.

If the triangle does not appear, check these common issues: the shader program has compilation or link errors (check the info logs), the attribute location is -1 (the name does not match the shader source), the buffer is not bound when configuring attributes, or the viewport dimensions do not match the canvas size.

Step 6: Add a Render Loop with requestAnimationFrame

A static triangle is not a game. To animate, wrap your drawing code in a function and call it with requestAnimationFrame. This browser API calls your function before each display refresh (typically 60 times per second), synchronized with the monitor's vertical sync for smooth animation.

Create a function called render that clears the canvas, issues the draw call, and then calls requestAnimationFrame(render) to schedule the next frame. The callback receives a high-resolution timestamp in milliseconds, which you can use to calculate elapsed time for frame-rate-independent animation. Store the previous frame's timestamp and compute the delta: deltaTime = (currentTime - lastTime) / 1000.0 to get seconds per frame.

With a render loop in place, you can now animate the triangle. Add a uniform variable to your vertex shader (for example, a float representing rotation angle), update it each frame using gl.uniform1f(), and apply the rotation in the shader by multiplying the position by a 2D rotation matrix. The triangle will spin smoothly, and you have the foundation of a WebGL game loop.

For a game-ready render loop, separate your update logic (physics, input, game state) from your render logic. Call the update function with the delta time, then call the render function. This separation allows you to run physics at a fixed timestep independent of the display refresh rate, which prevents game speed from varying with frame rate.

Key Takeaway

Drawing a triangle in WebGL requires five components: a context, compiled shaders, a vertex buffer, attribute configuration, and a draw call. Adding requestAnimationFrame creates a render loop that forms the foundation of every WebGL game. Master these steps, and every subsequent technique builds on the same pipeline.