Key concept — GLSL shaders running on the GPU
WebGL lets your JavaScript upload arrays of numbers (positions, sizes, colours) directly to the GPU. Two small programs written in GLSL then run in parallel across every dot simultaneously: the vertex shader converts data coordinates into screen coordinates, and the fragment shader decides the colour of each pixel. There is no CPU drawing loop — geometry lives in GPU memory and the hardware renders millions of points per frame, making this the fastest approach for large datasets. Unlike Canvas 2D, which bypasses the DOM but still draws on the CPU, WebGL moves the actual rasterization work onto the GPU, so the CPU is free while pixels are being painted.
// Vertex shader — runs once per dot, positions it in clip space
attribute vec2 a_pos; // data-space [0, 1]
attribute float a_size; // radius in physical pixels (CSS r × dpr)
attribute float a_hover; // 0 = normal, 1 = hovered
uniform vec2 u_res; // canvas CSS dimensions
uniform vec4 u_margin; // plot margins (left, top, right, bottom)
void main() {
float plotW = u_res.x - u_margin.x - u_margin.z;
float plotH = u_res.y - u_margin.y - u_margin.w;
float px = u_margin.x + a_pos.x * plotW;
float py = u_margin.y + (1.0 - a_pos.y) * plotH; // flip y
gl_Position = vec4(px/u_res.x*2.0-1.0, 1.0-py/u_res.y*2.0, 0.0, 1.0);
gl_PointSize = a_size * 2.0; // radius → diameter (both in physical px)
}
// Fragment shader — runs once per pixel, clips to circle + hover tint
void main() {
float d = length(gl_PointCoord - vec2(0.5));
float alpha = 1.0 - smoothstep(0.46, 0.5, d); // soft AA edge
if (alpha < 0.001) discard; // clip corners to circle
vec3 color = mix(
vec3(0.957, 0.745, 0.000), // otter shine gold
vec3(0.898, 1.000, 1.000), // otter think bright
v_hover
);
// stroke ring: otter sleep night near the edge
color = mix(color, vec3(0.039, 0.000, 0.196), smoothstep(0.38, 0.44, d));
gl_FragColor = vec4(color, mix(0.9, 1.0, v_hover) * alpha);
}
Hover & color
WebGL has no concept of objects — only raw vertices. Hit-testing runs on the CPU:
a nearest-neighbour loop each frame writes a 0.0 or 1.0 flag per
point into a typed array. That array is uploaded as a per-vertex attribute
(a_hover) and the fragment shader uses mix() to blend
otter shine gold → otter think bright without branching.
// CPU: nearest-neighbour search, result stored in hoverArr
for (let i = 0; i < count; i++) {
const d2 = (px - mouseX)**2 + (py - mouseY)**2;
if (d2 < minDist2) { minDist2 = d2; hoveredIdx = i; }
}
for (let i = 0; i < count; i++)
hoverArr[i] = i === hoveredIdx ? 1.0 : 0.0;
// Upload as a per-vertex attribute on every frame
gl.bufferData(gl.ARRAY_BUFFER, hoverArr, gl.DYNAMIC_DRAW);
// Fragment shader: GPU blends color based on the v_hover varying
vec3 blue = vec3(0.957, 0.745, 0.000); // otter shine gold
vec3 pink = vec3(0.898, 1.000, 1.000); // otter think bright
vec3 color = mix(blue, pink, v_hover); // 0.0 = blue, 1.0 = pink
float baseA = mix(0.9, 1.0, v_hover);
gl_FragColor = vec4(color, baseA * alpha);
See the complete implementation on GitHub (407 lines)