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.
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.
- Compute stride in bytes. Use
2 * Float32Array.BYTES_PER_ELEMENTfor tightly packed 2D pairs, then callgl.vertexAttribPointer(loc, 2, gl.FLOAT, false, stride, 0)followed bygl.enableVertexAttribArray(loc). - Declare
highpprecision in the vertex shader so large screen-space coordinates do not truncate on mobile GPUs. - Normalize pixel coordinates to clip space inside the shader, flipping Y to match canvas orientation, and set
gl_PointSizeexplicitly. - Update streaming data with
bufferSubDatainstead of reallocating withbufferData.
#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 callgl.viewport(0, 0, canvas.width, canvas.height)on every resize — otherwise points render blurry or offset. The subtle trap here is thatu_resolutionmust 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. highpprecision limits: ahighp floatin 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 thathighpcan represent exactly. Re-add the origin in the projection matrix.gl.POINTSsize cap: drivers enforce a maximumgl_PointSize, queryable viagl.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 callgl.deleteBuffer/gl.deletePrograminuseEffectcleanup oronBeforeUnmount— 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.
Related
- WebGL Fundamentals for Visualizations — full context, buffer, and shader lifecycle.
- Canvas 2D vs WebGL Comparison — whether the GPU path is worth it for your point count.
- Memory Management in Heavy Charts — disposing GPU buffers on teardown.