General relativity

Curved spacetime

There is no force in this simulation. The grid is bent by mass and the particles just go straight through it. What looks like attraction is geometry.

EXP-009

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

The mass follows your cursor, so you can drag the whole funnel around under the orbits. Widen the Schwarzschild radius and the throat opens; flatten the view angle and the surface collapses to the flat plane it would be with no mass at all.

The maths

G_μν = 8πG/c⁴ · T_μν

The Einstein field equations. Left side is curvature, right side is what is there. Matter tells spacetime how to bend; spacetime tells matter how to move.

r_s = 2GM/c²

The Schwarzschild radius. Compress a mass inside it and the curvature closes on itself. For the Sun that is about three kilometres.

δ = 4GM/c²b

Light deflection past a mass. Eddington measured it on the Sun during the 1919 eclipse and got twice the Newtonian value, which is what made Einstein famous overnight.

How it is built

The surface is Flamm paraboloid, z(r) = 2√(r_s(r − r_s)): the exact embedding of the Schwarzschild metric, drawn as a polar mesh of rings and spokes and projected with a tilt. The throat is the horizon. Real curvature is four-dimensional and has no outside to bulge into, so an embedding diagram is the honest way to draw it. Particles are integrated with Newtonian gravity in the flat plane and then projected onto the surface.

The code, step by step

Cut straight from lab.js. These are the real lines that run above, not a simplified version.

01

Flamm paraboloid

const zOf = r => 2 * Math.sqrt(rs * Math.max(0, r - rs));
const proj = (dx, dy) => {
  const r = Math.hypot(dx, dy);
  const z = zOf(Math.max(r, rs));
  return [cx + dx, cy + dy * tilt + (zOf(600) - z) * DEPTH * tilt];
};

This is the exact embedding surface of the Schwarzschild metric, not an invented well. The throat sits at the Schwarzschild radius and the surface flattens toward infinity.

02

A polar mesh, not a square one

const RINGS = 16, SPOKES = 32, RMAX = Math.max(w, h) * 0.62;
for (let i = 1; i <= RINGS; i++) {
  const r = rs + Math.pow(i / RINGS, 1.7) * RMAX;
  ctx.strokeStyle = `rgba(${acc},${(0.30 - i * 0.012).toFixed(3)})`;
  ctx.beginPath();
  for (let k = 0; k <= SPOKES; k++) {
    const a = (k / SPOKES) * TAU;
    const p = proj(r * Math.cos(a), r * Math.sin(a));
    k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]);
  }
  ctx.stroke();

Rings and spokes follow the symmetry of the problem. A square grid would cross itself near the throat, which is exactly what the first version of this did.

03

Integrate flat, draw curved

for (let s = 0; s < 2; s++) {
  const r = Math.hypot(b.x, b.y) + 4;
  const a = 2200 / (r * r) * 0.5;
  b.vx -= (b.x / r) * a; b.vy -= (b.y / r) * a;
  b.x += b.vx * 0.5; b.y += b.vy * 0.5;

The particles are integrated with Newtonian gravity in the flat plane and only then projected onto the surface. At these speeds general relativity reduces to Newton, so the two agree.

Why it matters

It replaced a force acting instantly at a distance with a field that bends and propagates at the speed of light. That prediction, gravitational waves, was confirmed in 2015 by LIGO measuring a length change a thousand times smaller than a proton.

The whole thing

Everything above, in one piece. This is the entire experiment as it lives in lab.js.

FULL

lab.js · spacetime

{ id: 'spacetime',
      name: L('Curved spacetime', 'Espaciotiempo curvo'),
      note: L('The embedding diagram of a black hole. Orbits are straight lines on a bent surface.',
              'El diagrama de embebimiento de un agujero negro. Las órbitas son rectas sobre una superficie doblada.'),
      params: [
        { key: 'rs',    label: L('Schwarzschild radius', 'Radio de Schwarzschild'), min: 4, max: 40, step: 1, def: 16, unit: 'px' },
        { key: 'tilt',  label: L('View angle', 'Ángulo de vista'), min: 0.1, max: 0.9, step: 0.02, def: 0.42 },
        { key: 'orbit', label: L('Orbiting particles', 'Partículas en órbita'), min: 0, max: 6, step: 1, def: 3 }
      ],
      make(w, h) {
        let bodies = [], lw = w, lh = h;
        const seed = (w, h) => {
          bodies = [];
          for (let i = 0; i < 6; i++) {
            const r = Math.min(w, h) * (0.14 + i * 0.05), a = i * 1.7;
            const v = Math.sqrt(2200 / r);
            bodies.push({ x: r * Math.cos(a), y: r * Math.sin(a),
                          vx: -Math.sin(a) * v, vy: Math.cos(a) * v, p: [] });
          }
        };
        seed(w, h);
        return {
          resize(w, h) { lw = w; lh = h; seed(w, h); },
          reset() { seed(lw, lh); },
          step(ctx, w, h, t, acc, P, M) {
            const cx = M.in ? M.x : w / 2, cy = (M.in ? M.y : h / 2) - h * 0.06;
            const rs = P.rs, tilt = P.tilt, DEPTH = 1.5;

            // Paraboloide de Flamm: z(r) = 2·√(rs·(r − rs)) para r ≥ rs.
            // Es la superficie de embebimiento exacta de Schwarzschild, no un
            // pozo inventado: la garganta está en rs y se aplana hacia el infinito.
            const zOf = r => 2 * Math.sqrt(rs * Math.max(0, r - rs));
            const proj = (dx, dy) => {
              const r = Math.hypot(dx, dy);
              const z = zOf(Math.max(r, rs));
              return [cx + dx, cy + dy * tilt + (zOf(600) - z) * DEPTH * tilt];
            };

            // Malla polar: anillos y radios, que es como se dibuja el diagrama
            ctx.lineWidth = 1;
            const RINGS = 16, SPOKES = 32, RMAX = Math.max(w, h) * 0.62;
            for (let i = 1; i <= RINGS; i++) {
              const r = rs + Math.pow(i / RINGS, 1.7) * RMAX;
              ctx.strokeStyle = `rgba(${acc},${(0.30 - i * 0.012).toFixed(3)})`;
              ctx.beginPath();
              for (let k = 0; k <= SPOKES; k++) {
                const a = (k / SPOKES) * TAU;
                const p = proj(r * Math.cos(a), r * Math.sin(a));
                k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]);
              }
              ctx.stroke();
            }
            ctx.strokeStyle = `rgba(${acc},0.14)`;
            for (let k = 0; k < SPOKES; k++) {
              const a = (k / SPOKES) * TAU;
              ctx.beginPath();
              for (let i = 0; i <= RINGS; i++) {
                const r = rs + Math.pow(i / RINGS, 1.7) * RMAX;
                const p = proj(r * Math.cos(a), r * Math.sin(a));
                i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]);
              }
              ctx.stroke();
            }

            // El horizonte: la garganta del embudo
            ctx.strokeStyle = `rgba(${acc},0.9)`; ctx.lineWidth = 1.4;
            ctx.beginPath();
            for (let k = 0; k <= SPOKES; k++) {
              const a = (k / SPOKES) * TAU;
              const p = proj(rs * Math.cos(a), rs * Math.sin(a));
              k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]);
            }
            ctx.stroke();

            // Partículas: se integran en el plano y se proyectan a la superficie
            const n = P.orbit | 0;
            for (let i = 0; i < n && i < bodies.length; i++) {
              const b = bodies[i];
              for (let s = 0; s < 2; s++) {
                const r = Math.hypot(b.x, b.y) + 4;
                const a = 2200 / (r * r) * 0.5;
                b.vx -= (b.x / r) * a; b.vy -= (b.y / r) * a;
                b.x += b.vx * 0.5; b.y += b.vy * 0.5;
              }
              b.p.push([b.x, b.y]); if (b.p.length > 520) b.p.shift();
              ctx.strokeStyle = `rgba(${acc},0.6)`; ctx.lineWidth = 1.1;
              ctx.beginPath();
              b.p.forEach((q, k) => { const pr = proj(q[0], q[1]);
                k ? ctx.lineTo(pr[0], pr[1]) : ctx.moveTo(pr[0], pr[1]); });
              ctx.stroke();
              const pb = proj(b.x, b.y);
              ctx.fillStyle = `rgba(${acc},0.95)`;
              ctx.beginPath(); ctx.arc(pb[0], pb[1], 3.2, 0, TAU); ctx.fill();
            }

            ctx.fillStyle = `rgba(${acc},0.75)`; ctx.font = '11px monospace';
            ctx.fillText('r_s = ' + rs + 'px', 12, h - 12);
          }
        };
      }
    },