IInbox
Composed

Mesh gradient

A displaced 3D surface with an orbit camera, in raw WebGL2 — no three.js.

Everything else here is a fullscreen fragment shader: no buffers, no matrices, no depth. This is the opposite — real geometry, a real camera and a depth buffer, in about a hundred lines of GLSL and one vertex array.

Live at /lab/gradient. Drag to orbit, scroll to zoom.

Installation

npx shadcn@latest add https://ui.whoisroktim.lol/r/mesh-gradient.json

Why not three.js

Nothing here needs a scene. There is one mesh and one light. A library that manages hierarchies, materials and render queues would be larger than the feature, and it would hide the two things that actually matter: how the surface is displaced, and where its normals come from.

The normals are the whole thing

The vertex shader displaces each vertex along the base surface normal, then recomputes the normal by finite differences along a tangent basis.

vec3 t1 = normalize(cross(ref, baseN));
vec3 t2 = cross(baseN, t1);
float h  = disp(a_pos, u_time);
float hx = disp(a_pos + t1 * e, u_time);
float hy = disp(a_pos + t2 * e, u_time);
vec3 n = normalize(baseN - t1 * ((hx - h) / e) - t2 * ((hy - h) / e));

Displacing the vertices and keeping the original normals is the classic mistake, and it is subtle: the silhouette moves correctly, so it looks like it works. But the shading stays flat, and the result reads as a texture painted on a still surface rather than as a surface that is moving.

Details that only show up when they are wrong

lookAt has a fallback basis. A camera pointing straight down has an eye-to-target vector parallel to up, the cross product collapses, and every value in the matrix becomes NaN — the scene simply vanishes. The orbit control clamps elevation, but the guard belongs in the matrix function too, because it is a library and its caller may not know.

Culling is off. A displaced plane folds, and the far side of a fold is a real surface. The fragment shader flips the normal on back faces rather than shading them black, which would leave holes.

Matrices are column-major, like GL. Writing rows and transposing by accident produces a scene that is subtly, unfixably wrong rather than obviously broken — which is why tests/dom/mesh.test.ts checks that a × b applies b first, and that perspective divides x by the aspect rather than y.

Fill is per shape. A plane wants to run past every edge; that full-bleed look is the point. A sphere at the same scale loses its silhouette and becomes a wash, throwing away the only reason to render geometry at all.