WebGL Shader Basics for 2D Data Points

Your 2D scatter points render at (0,0), collapse into a single line, or vanish entirely — the classic signature of a vertex attribute stride or clip-space transform mistake.

This is a focused fix guide under the WebGL Fundamentals for Visualizations section, which covers the full context, buffer, and shader lifecycle. Here we zero in on the single most common failure: getting a Float32Array of 2D coordinates to actually appear on screen.

The reason this trips up almost everyone the first time is that WebGL gives you no error and no exception when the geometry is misconfigured — it dutifully renders something, just not what you expect. The GPU reads whatever bytes the attribute pointer tells it to read, transforms them through whatever the shader says, and rasterizes the result. If the stride is wrong, every vertex reads overlapping or shifted bytes and the points pile up at the origin or smear into a line. If the clip-space math is wrong, the points are technically drawn but land outside the [-1, 1] visible cube and get clipped away. If gl_PointSize is unset, each point is a single pixel that you will swear is missing. None of these are “errors” to WebGL — they are valid pipelines producing invisible output, which is exactly why the diagnostic checklist below works through the pipeline stage by stage rather than waiting for a console message that will never come.

Diagnostic checklist

Verify these root-cause hypotheses before touching shader math:

Work this list top to bottom. Each item corresponds to a distinct pipeline stage, and because WebGL never raises an exception for a misconfigured-but-valid pipeline, the only reliable debugging strategy is to confirm each stage in isolation before moving to the next. The two cheapest wins are usually polling gl.getError() after setup and verifying the attribute location resolved to a non-negative index — between them they catch compilation failures, link failures, and attributes that the GLSL compiler stripped because the shader never actually reads them.

Interleaved 2D buffer stride and offset A packed Float32Array of x and y pairs with a stride of 8 bytes and offset 0 for the position attribute. x0 y0 x1 y1 x2 y2 0 8 16 (bytes) stride = 8 bytes size 2 · type FLOAT · offset 0 · stride 2 × 4 bytes
An interleaved buffer of (x, y) pairs: each vertex is 8 bytes, so the position attribute uses size 2, stride 8, offset 0.

Broken vs fixed

The single most common bug is expressing stride in array elements instead of bytes. The stride and offset arguments to gl.vertexAttribPointer are both measured in bytes, while almost every other index you work with in JavaScript — array indices, the count you pass to drawArrays — is measured in elements. That impedance mismatch is the trap. A Float32Array stores each number in 4 bytes, so a tightly packed (x, y) pair occupies 8 bytes; passing 2 as the stride tells the GPU to advance only 2 bytes per vertex, so the second vertex starts halfway through the first vertex’s x value and reads pure garbage from then on.

// ❌ BROKEN: stride passed as element count, not bytes
const positionBuffer: WebGLBuffer = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(
  positionAttributeLocation,
  2,          // components per vertex
  gl.FLOAT,
  false,
  2,          // BUG: this is "2 bytes", not "2 floats" — GPU reads garbage
  0,
);
// Result: points collapse to (0,0) or form a single distorted line.
// ✅ FIXED: stride computed in bytes from BYTES_PER_ELEMENT
const BYTES_PER_ELEMENT: number = Float32Array.BYTES_PER_ELEMENT; // 4
const stride: number = 2 * BYTES_PER_ELEMENT; // 8 bytes per (x, y) vertex
const offset = 0; // start at the beginning of the buffer

gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(
  positionAttributeLocation,
  2,          // size: 2 components (x, y)
  gl.FLOAT,   // type
  false,      // normalize
  stride,     // stride: bytes to jump to the next vertex
  offset,     // offset: bytes to the first component
);
gl.enableVertexAttribArray(positionAttributeLocation);
// Result: every point reads its true (x, y) and lands at the correct screen position.

Step-by-step fix

Apply these corrections in order to resolve attribute binding and coordinate transformation failures. The clip-space normalization in step 3 is worth understanding rather than copying blindly. The GPU only draws geometry whose coordinates fall inside normalized device coordinates (NDC), a cube running from -1 to +1 on each axis, with +Y pointing up. Your data, however, almost certainly arrives in pixel space — 0 to canvas.width across, 0 to canvas.height down, +Y pointing down. The three lines in the shader bridge those spaces: dividing by u_resolution maps pixels into [0, 1], multiplying by 2.0 stretches that to [0, 2], and subtracting 1.0 shifts it to [-1, 1]. The final multiply by vec2(1.0, -1.0) flips the Y axis so that increasing data values move upward on screen rather than downward. Skip the flip and your chart renders upside down; skip the normalization entirely and a point at pixel (400, 300) lands far outside the visible cube and is clipped away — the “renders nothing” symptom with a perfectly valid pipeline behind it.

  1. Compute stride in bytes. Use 2 * Float32Array.BYTES_PER_ELEMENT for tightly packed 2D pairs, then call gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, stride, 0) followed by gl.enableVertexAttribArray(loc).
  2. Declare highp precision in the vertex shader so large screen-space coordinates do not truncate on mobile GPUs.
  3. Normalize pixel coordinates to clip space inside the shader, flipping Y to match canvas orientation, and set gl_PointSize explicitly.
  4. Update streaming data with bufferSubData instead of reallocating with bufferData.
#version 300 es
precision highp float;

in vec2 a_position;
uniform vec2 u_resolution;

void main() {
  // Convert pixel coordinates to clip space [-1, 1]
  vec2 zeroToOne = a_position / u_resolution;
  vec2 zeroToTwo = zeroToOne * 2.0;
  vec2 clipSpace = zeroToTwo - 1.0;

  // Flip Y-axis to match standard 2D canvas coordinate space
  gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
  gl_PointSize = 4.0; // explicit size prevents driver defaults of 1.0
}
// Streaming update without reallocation
function updatePointStream(
  gl: WebGL2RenderingContext,
  buffer: WebGLBuffer,
  data: Float32Array,
  offsetInBytes: number,
): void {
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  // PERF: bufferSubData reuses the existing allocation; no GC, no VRAM churn
  gl.bufferSubData(gl.ARRAY_BUFFER, offsetInBytes, data);
}

Verification

Confirm the fix with three cheap assertions during development:

// 1. The pipeline is error-free after setup.
console.assert(gl.getError() === gl.NO_ERROR, 'WebGL error during setup');

// 2. The attribute location resolved (>= 0 means the shader uses it).
const loc: number = gl.getAttribLocation(program, 'a_position');
console.assert(loc >= 0, 'a_position not found — was it optimized out?');

// 3. The draw count matches the buffer (2 floats per vertex).
console.assert(
  drawCount === buffer.length / 2,
  `draw count ${drawCount} != ${buffer.length / 2} vertices`,
);

If all three pass and points still do not appear, open the Chrome DevTools Performance tab, record a three-second trace, and confirm a WebGL draw task is actually firing rather than being skipped.

A second, faster sanity check isolates the transform from the data: temporarily hard-code a single known point at the center of clip space directly in the shader, gl_Position = vec4(0.0, 0.0, 0.0, 1.0); gl_PointSize = 20.0;, ignoring a_position entirely. If a large dot appears dead center, your context, program, viewport, and draw call are all healthy and the bug lives in the attribute layout or the coordinate math. If even the hard-coded dot fails to show, the problem is further upstream — usually a program that failed to link, a missing gl.useProgram, or a gl.viewport that does not match the canvas backing store. Bisecting the pipeline this way turns a vague “nothing renders” into a precise stage to fix.

Edge cases & gotchas

  • High-DPI / Retina: match the canvas backing store to CSS size times window.devicePixelRatio, and call gl.viewport(0, 0, canvas.width, canvas.height) on every resize — otherwise points render blurry or offset. The subtle trap here is that u_resolution must be set to the backing-store dimensions (canvas.width/canvas.height), not the CSS dimensions (clientWidth/clientHeight); mixing the two leaves your points correctly sized but positioned at half or double their intended coordinates on a 2× display.
  • highp precision limits: a highp float in GLSL guarantees only about 23 bits of mantissa, roughly seven significant decimal digits. For datasets spanning millions of units — timestamps in milliseconds, geographic coordinates in meters — that precision runs out and points visibly snap to a grid as you zoom in. The fix is a local origin: subtract the viewport center from every coordinate on the CPU (in double-precision JavaScript) before uploading, so the shader only ever sees small offsets that highp can represent exactly. Re-add the origin in the projection matrix.
  • gl.POINTS size cap: drivers enforce a maximum gl_PointSize, queryable via gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE). Requesting a larger size silently clamps, so large markers on some hardware top out around 64 pixels; switch to instanced quads if you need bigger or textured markers.
  • React/Vue context init: initialize the context in useRef/onMounted, never in the render function, and call gl.deleteBuffer/gl.deleteProgram in useEffect cleanup or onBeforeUnmount — WebGL resources are not garbage collected. Under React 18 Strict Mode the effect mounts, unmounts, and remounts in development, so a context created without idempotent cleanup will leak a GPU context on every hot reload until the browser refuses to grant more.