WebGL Fundamentals: 3D Graphics in the Browser
WebGL is a browser API based on OpenGL ES that enables GPU-accelerated 2D and 3D graphics through shaders, buffers, and the rendering pipeline — no plugins required.
What You’ll Learn
You’ll understand the WebGL rendering pipeline, write vertex and fragment shaders in GLSL, create buffers for geometry data, apply textures, use transformation matrices, and build a basic rotating 3D cube — all without libraries.
Why It Matters
Browser-based 3D is everywhere: product configurators, data visualization, games, and AR/VR. Doda Browser’s hardware-accelerated tab compositor uses GPU primitives similar to WebGL. Mastering WebGL fundamentals makes Three.js, Babylon.js, and game frameworks much easier to understand.
Real-World Use
When you view a 3D product model on a shopping site, configure a car’s paint color interactively, or play a browser game, WebGL is doing the work. Even Figma’s canvas rendering uses WebGL internally for performance.
The WebGL Pipeline
flowchart LR
A[JavaScript] --> B[Vertex Buffer]
B --> C[Vertex Shader]
C --> D[Rasterization]
D --> E[Fragment Shader]
E --> F[Frame Buffer]
F --> G[Screen]
style A fill:#3b82f6,color:#fff
style C fill:#8b5cf6,color:#fff
style E fill:#dc2626,color:#fff
Step 1: WebGL Context Setup
const canvas = document.getElementById("gl-canvas");
const gl = canvas.getContext("webgl");
if (!gl) {
alert("WebGL not supported! Try Chrome or Firefox.");
}
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.1, 0.1, 0.2, 1.0); // dark blue-gray
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST);Expected output: A dark blue-gray canvas. If you see an alert instead, your browser doesn’t support WebGL.
Step 2: Writing Shaders with GLSL
Shaders are programs that run on the GPU. You write them as strings in JavaScript:
const vertexShaderSource = `
attribute vec3 aPosition;
attribute vec3 aColor;
varying vec3 vColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
void main(void) {
gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aPosition, 1.0);
vColor = aColor;
}
`;
const fragmentShaderSource = `
precision mediump float;
varying vec3 vColor;
void main(void) {
gl_FragColor = vec4(vColor, 1.0);
}
`;
function createShader(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vsSource, fsSource) {
const vs = createShader(gl, vsSource, gl.VERTEX_SHADER);
const fs = createShader(gl, fsSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return null;
}
return program;
}
const program = createProgram(gl, vertexShaderSource, fragmentShaderSource);
gl.useProgram(program);Expected output: No visible change yet — but you’ve compiled and linked shaders on the GPU. Check the console for any compilation errors.
Step 3: Creating Geometry Buffers
Buffers send vertex data (positions, colors) from JavaScript to the GPU:
// A cube: 36 vertices (6 faces × 2 triangles × 3 vertices)
const positions = new Float32Array([
// Front face
-0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5,
-0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
// Back face
-0.5, -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5,
-0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5,
// Additional faces omitted for brevity — add 4 more faces
]);
const colors = new Float32Array([
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // front: red
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // back: green
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0,
// Add more colors for remaining faces
]);
function createBuffer(gl, data) {
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
return buffer;
}
const positionBuffer = createBuffer(gl, positions);
const colorBuffer = createBuffer(gl, colors);
// Connect buffers to shader attributes
const aPosition = gl.getAttribLocation(program, "aPosition");
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aPosition);
const aColor = gl.getAttribLocation(program, "aColor");
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aColor);Expected output: Nothing visible yet — the cube needs projection and model-view matrices to be positioned correctly.
Step 4: Transformation Matrices
Matrices move, rotate, and scale your 3D objects. Here’s a minimal matrix library:
function createPerspectiveMatrix(fov, aspect, near, far) {
const f = 1.0 / Math.tan(fov / 2);
const nf = 1 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, 2 * far * near * nf, 0,
]);
}
function createRotationYMatrix(angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return new Float32Array([
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1,
]);
}
// Render loop
const uProjection = gl.getUniformLocation(program, "uProjectionMatrix");
const uModelView = gl.getUniformLocation(program, "uModelViewMatrix");
const projection = createPerspectiveMatrix(
Math.PI / 4, canvas.width / canvas.height, 0.1, 100.0
);
gl.uniformMatrix4fv(uProjection, false, projection);
let angle = 0;
function renderLoop() {
angle += 0.01;
const modelView = createRotationYMatrix(angle);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniformMatrix4fv(uModelView, false, modelView);
gl.drawArrays(gl.TRIANGLES, 0, 36);
requestAnimationFrame(renderLoop);
}
renderLoop();Expected output: A 3D cube rotating around the Y axis. Each face has a different color. The cube appears to have depth thanks to the perspective projection.
Step 5: Textures
function createTexture(gl, image) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
// Load an image then create texture
const img = new Image();
img.onload = () => createTexture(gl, img);
img.src = "texture.png";Expected output: The cube faces now display the loaded image as a surface texture instead of flat colors.
WebGL vs Three.js Comparison
| Aspect | WebGL | Three.js |
|---|---|---|
| Boilerplate | Shader compilation, buffer setup, matrix math | 5 lines for a rotating cube |
| Learning curve | Steep — you must understand the pipeline | Gentle — abstracted API |
| Control | Full GPU access | Limited by abstraction |
| Performance | Maximum — you control everything | Near-native — very optimized |
| Use case | Custom rendering, research, learning | Production 3D apps, games |
Common Errors
1. gl.getShaderParameter returns false for compilation
GLSL syntax errors are common. Missing semicolons, wrong variable types, or mismatched precision qualifiers cause shader compilation failures. Use gl.getShaderInfoLog(shader) to see the exact error.
2. Nothing renders but no errors
You probably forgot to set uniforms (projection/model-view matrices) or didn’t call gl.useProgram(program). Also verify your vertex count in gl.drawArrays matches the actual data.
3. Texture is black or white
The image may not have loaded yet — textures must exist in GPU memory when drawArrays is called. Use img.onload to ensure the image is fully loaded before binding. Also check that the image origin allows CORS.
4. WebGL context lost
If the GPU runs out of memory (too many textures, too large a canvas), or the system enters power-saving mode, the context can be lost. Listen for webglcontextlost events and re-create resources.
5. Attribute location returns -1
The shader compiler may have optimized away unused attributes. If a vertex attribute isn’t used in the vertex shader output or doesn’t affect gl_Position, GLSL optimizers remove it. Use the attribute in your shader code even if it’s a pass-through.
Practice Questions
1. What is the difference between a vertex shader and a fragment shader? The vertex shader runs once per vertex, transforming 3D positions to screen space. The fragment shader runs once per pixel (fragment), determining the final color. Vertex shaders handle geometry transformation; fragment shaders handle coloring and lighting.
2. Why do we need both projection and model-view matrices? The model-view matrix moves and rotates objects in world space and positions them relative to the camera. The projection matrix maps the 3D scene to the 2D screen (perspective or orthographic). Separating them allows independent camera and object manipulation.
3. What is a VBO (Vertex Buffer Object)?
A buffer stored in GPU memory that holds vertex data (positions, normals, texture coordinates). It’s created with gl.createBuffer() and fed data with gl.bufferData(). Using VBOs is far faster than sending vertex data per frame.
4. How does WebGL handle coordinate systems? WebGL uses a normalized device coordinate (NDC) system where X and Y range from -1 to 1, and Z from -1 to 1 (clip space). The projection matrix transforms your world coordinates into this space.
5. Challenge: Add lighting to the cube
Add a vec3 aNormal attribute and pass it to the fragment shader. In the fragment shader, compute diffuse lighting: float diff = max(dot(normal, lightDirection), 0.0). Multiply the color by diff to get a lit cube.
FAQ
What’s Next
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro