Newton said mass pulls. Einstein said mass bends, and things follow the bend. This is a lattice of space with a mass in it, and there is no force anywhere in the code.
EXP-016
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Move the mass with the cursor. Notice that the lattice never moves as a whole: only the spacing changes, and only near the mass. Far away the cells stay square, which is what asymptotically flat means. Set the mass to zero and the whole thing is a plain cube of straight lines, because with nothing there space has nothing to do.
The maths
G_μν = 8πG/c⁴ · T_μν
Matter on the right, curvature on the left. Ten coupled nonlinear equations saying the same thing in both directions: what is there decides the shape, and the shape decides how things move.
δ∫ds = 0
A free particle takes the longest proper time between two events. It is not being pushed; it is going straight, and straight in a curved geometry is not what it looks like from outside.
g_μν vs Γ
The distortion here is of distances, not of a rubber surface. What changes near the mass is what a ruler reads between two lattice points, and every grid line shows exactly that.
How it is built
A three dimensional lattice, drawn as lines along all three axes. Every vertex is displaced toward the mass by an amount falling as one over distance squared, with a soft core so the grid cannot fold through itself where the approximation stops being useful. Lines are sorted back to front before drawing and brightened as they approach the mass, which is the same job the blue to green gradient does in the classic illustrations.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
Each vertex moves toward the mass by an amount falling as one over distance squared. The clamp against r times 0.75 is what keeps the lattice from folding through itself at the centre, where this approximation stops being the physics anyway.
02
Yaw, tilt, perspective
const proj = q => {
const x = q[0] * cy - q[2] * sy;
const z = q[0] * sy + q[2] * cy;
const y = q[1] * ct - z * st;
const zz = q[1] * st + z * ct;
const per = 1 / (1 + zz * 0.22); // perspectiva
return [ox + x * S * per, oy - y * S * per, zz, per];
};
Three dimensions flattened in one expression: rotate about the vertical axis, tip the camera, then divide by depth. The perspective divide is what makes the far face of the cube read as further away rather than just smaller.
03
Subdivide or you see nothing
for (let k = 0; k <= N * SUB; k++) {
const v = -1 + (2 * k) / (N * SUB);
const p = tpl.slice(); p[u] = v;
const q = warp(p);
const pr = proj(q);
pts.push(pr);
depth += pr[2];
near = Math.max(near, 1 / (1 + q[3] * q[3] * 2.2));
Each lattice edge is sampled at eight points instead of two. Drawing only the endpoints would give straight segments between warped corners and the curvature would vanish entirely, which is the single mistake that ruins this diagram.
04
Back to front
lines.sort((a, b) => b.depth - a.depth);
Without sorting, near and far lines interleave arbitrarily and the cube collapses into a flat tangle. This single line is the difference between a volume and a mess.
Why it matters
The picture has one honest limit worth stating: it draws space bending in space, and real curvature is intrinsic. It needs no outside to bend into, and it involves time as much as it involves distance. What survives the simplification is the important part. Gravity is not something that reaches across a gap and pulls. It is what a straight line looks like when the geometry is not flat.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · curvature
{ id: 'curvature',
name: L('Gravity is geometry', 'La gravedad es geometría'),
note: L('A lattice of space with a mass in it. Move the mass and watch the grid answer.',
'Una retícula de espacio con una masa dentro. Mueve la masa y mira responder a la rejilla.'),
params: [
{ key: 'mass', label: L('Mass', 'Masa'), min: 0, max: 3, step: 0.05, def: 1.2 },
{ key: 'div', label: L('Lattice divisions', 'Divisiones de la retícula'), min: 3, max: 8, step: 1, def: 6 },
{ key: 'tilt', label: L('View tilt', 'Inclinación de la vista'), min: 0.05, max: 1.1, step: 0.02, def: 0.42 }
],
make() {
let yaw = 0.6;
return {
reset() { yaw = 0.6; },
step(ctx, w, h, t, acc, P, M) {
const N = P.div | 0, SUB = 8; // SUB: puntos por tramo, para que la curva se vea
const tilt = P.tilt;
yaw += 0.0022;
// La masa se mueve con el cursor dentro de la retícula
const mx = M.in ? (M.x / w - 0.5) * 2.1 : Math.cos(t * 0.35) * 0.5;
const my = M.in ? (M.y / h - 0.5) * -2.1 : Math.sin(t * 0.5) * 0.4;
const Mp = [mx, my, 0];
const rgb = acc.split(',').map(Number);
// Desplazamiento de cada vértice hacia la masa. La caída 1/r² con un
// núcleo suave evita que la rejilla se invierta sobre sí misma cerca
// del centro, que es donde la aproximación deja de valer igual.
const K = P.mass * 0.30;
const warp = p => {
const dx = p[0] - Mp[0], dy = p[1] - Mp[1], dz = p[2] - Mp[2];
const r2 = dx * dx + dy * dy + dz * dz;
const r = Math.sqrt(r2) + 1e-4;
const s = Math.min(r * 0.75, K / (r2 + 0.05));
return [p[0] - (dx / r) * s, p[1] - (dy / r) * s, p[2] - (dz / r) * s, r];
};
// Proyección: giro en yaw, inclinación, y perspectiva suave
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const ct = Math.cos(tilt), st = Math.sin(tilt);
const S = Math.min(w, h) * 0.30, ox = w / 2, oy = h / 2;
const proj = q => {
const x = q[0] * cy - q[2] * sy;
const z = q[0] * sy + q[2] * cy;
const y = q[1] * ct - z * st;
const zz = q[1] * st + z * ct;
const per = 1 / (1 + zz * 0.22); // perspectiva
return [ox + x * S * per, oy - y * S * per, zz, per];
};
// Todas las líneas de la retícula, en los tres ejes
const lines = [];
const at = (u, i, j) => {
const a = -1 + (2 * i) / N, b = -1 + (2 * j) / N;
return u === 0 ? [null, a, b] : u === 1 ? [a, null, b] : [a, b, null];
};
for (let u = 0; u < 3; u++) {
for (let i = 0; i <= N; i++) {
for (let j = 0; j <= N; j++) {
const tpl = at(u, i, j), pts = [];
let depth = 0, near = 0;
for (let k = 0; k <= N * SUB; k++) {
const v = -1 + (2 * k) / (N * SUB);
const p = tpl.slice(); p[u] = v;
const q = warp(p);
const pr = proj(q);
pts.push(pr);
depth += pr[2];
near = Math.max(near, 1 / (1 + q[3] * q[3] * 2.2));
}
lines.push({ pts, depth: depth / pts.length, near });
}
}
}
// De atrás hacia adelante, para que la profundidad se lea
lines.sort((a, b) => b.depth - a.depth);
for (const L of lines) {
// Cerca de la masa la línea se aclara hacia blanco: el mismo
// recurso del gradiente azul→verde de las ilustraciones clásicas
const n = Math.min(1, L.near * 1.5);
const r = rgb[0] + (245 - rgb[0]) * n;
const g = rgb[1] + (250 - rgb[1]) * n;
const b = rgb[2] + (255 - rgb[2]) * n;
const fog = Math.max(0.10, Math.min(0.75, 0.5 - L.depth * 0.16));
ctx.strokeStyle = `rgba(${r | 0},${g | 0},${b | 0},${(fog * (0.45 + n * 0.55)).toFixed(3)})`;
ctx.lineWidth = 0.8 + n * 1.2;
ctx.beginPath();
L.pts.forEach((p, k) => k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
ctx.stroke();
}
// La masa
const pm = proj([Mp[0], Mp[1], Mp[2], 0]);
const R = (7 + P.mass * 5) * pm[3];
const gg = ctx.createRadialGradient(pm[0], pm[1], 0, pm[0], pm[1], R * 3);
gg.addColorStop(0, 'rgba(255,255,255,0.95)');
gg.addColorStop(0.35, `rgba(${acc},0.5)`);
gg.addColorStop(1, `rgba(${acc},0)`);
ctx.fillStyle = gg;
ctx.beginPath(); ctx.arc(pm[0], pm[1], R * 3, 0, TAU); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(pm[0], pm[1], R * 0.5, 0, TAU); ctx.fill();
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('no force is drawn here — only distances', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText('mass ' + P.mass.toFixed(2) + ' lattice ' + N + '³', 12, h - 12);
}
};
}
},