General relativity

Wormhole

Two regions of space joined by a tunnel that is shorter than the distance between them. Nothing in general relativity forbids the geometry. Keeping it open is the hard part.

EXP-010

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

Follow one traveller all the way through. It descends one sheet, passes the bright ring at the narrowest point and keeps going into the other sheet without turning around or slowing down. That is the whole difference from a black hole: the throat is a place you pass through, not a place you end.

The maths

ds² = −c²dt² + dl² + (b₀² + l²)dΩ²

The Morris-Thorne metric. l is proper radial distance and runs from minus to plus infinity: it passes straight through the throat instead of stopping at it, which is what makes this traversable rather than a black hole.

r(l) = √(b₀² + l²)

The circumferential radius. At l = 0 it reaches its minimum b₀, the throat, and grows on both sides. There is no singularity anywhere and no horizon.

z(l) = b₀·arcsinh(l/b₀)

The embedding height, obtained by integrating the shape function b(r) = b₀²/r. This is the exact surface drawn on screen, not an artistic hourglass.

ρ + p < 0

The catch. Holding the throat open requires matter that violates the null energy condition, meaning negative energy density as measured by a passing light ray. Nothing known does this in bulk.

How it is built

The surface is generated from the metric rather than drawn by hand: rings of constant l and meridians of constant angle, each vertex placed at r(l) and z(l). Rings are sorted by height before drawing so the far side of the tunnel renders behind the near side. The travellers advance at constant rate in l, which is proper distance, so they cross the throat and come out the other sheet without anything special happening at l = 0.

The code, step by step

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

01

The metric, in two lines

const rOf = l => S * Math.sqrt(1 + l * l);
const zOf = l => S * Math.asinh(l);

These are not shape parameters chosen to look right. They come from integrating the Morris-Thorne shape function, and every vertex on screen is placed by them. Change b0 and the whole surface reshapes correctly because the geometry is doing the work.

02

Azimuth, then camera tilt

const proj = (l, a) => {
  const r = rOf(l), z = zOf(l);
  const x = r * Math.cos(a), y = r * Math.sin(a);
  const xr = x * ca - y * sa, yr = x * sa + y * ca;
  return [cx + xr, cy + yr * ky - z * kz];
};

A point on the surface is turned into three dimensions, rotated about the vertical axis, and only then flattened. The cursor drives both angles: horizontal spins it, vertical moves the camera from edge-on to looking down the tunnel.

03

Sort by depth or it looks flat

ls.sort((A, B) => zOf(B) - zOf(A));    // los de arriba primero
ctx.lineWidth = 1;
for (const l of ls) {
  const near = 1 - Math.min(1, Math.abs(l) / LMAX);
  ctx.strokeStyle = `rgba(${acc},${(0.10 + near * 0.45).toFixed(3)})`;
  ctx.beginPath();
  for (let k = 0; k <= SEG; k++) {
    const pnt = proj(l, (k / SEG) * TAU);
    k ? ctx.lineTo(pnt[0], pnt[1]) : ctx.moveTo(pnt[0], pnt[1]);
  }
  ctx.stroke();

Rings are drawn from the top down so the far wall of the tunnel is laid before the near one. Without this the wireframe reads as a flat pattern instead of a surface with an inside.

04

Crossing is unremarkable

tr.l += tr.dir * 0.016;
if (tr.l > LMAX) { tr.l = LMAX; tr.dir = -1; tr.p.length = 0; }
if (tr.l < -LMAX) { tr.l = -LMAX; tr.dir = 1; tr.p.length = 0; }

The traveller advances at a constant rate in l, which is proper distance. Note there is no special case at l = 0: the throat is just another value of the coordinate, and that is exactly the point of a traversable wormhole.

Why it matters

Morris and Thorne wrote this down in 1988 because Carl Sagan asked them for a way to move a character across the galaxy in Contact without breaking physics. They worked backwards: assume the trip is possible, then derive what the metric and the matter would have to be. The answer was a clean geometry and an impossible material, and that paper started the modern field.

The whole thing

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

FULL

lab.js · wormhole

{ id: 'wormhole',
      name: L('Wormhole', 'Agujero de gusano'),
      note: L('A Morris-Thorne throat joining two sheets. Watch something cross it.',
              'Una garganta de Morris-Thorne uniendo dos hojas. Mira algo cruzarla.'),
      params: [
        { key: 'reach',   label: L('Sheet extent', 'Alcance de las hojas'), min: 1.4, max: 6, step: 0.1, def: 3.2 },
        { key: 'tilt',    label: L('View angle', 'Ángulo de vista'), min: 0.12, max: 0.9, step: 0.02, def: 0.34 },
        { key: 'travel',  label: L('Travellers', 'Viajeros'), min: 0, max: 4, step: 1, def: 2 }
      ],
      make() {
        // Métrica de Morris-Thorne con función de forma b(r) = b₀²/r.
        // La coordenada radial propia l va de −∞ a +∞ y CRUZA la garganta:
        //   r(l) = √(b₀² + l²)        radio circunferencial
        //   z(l) = b₀·arcsinh(l/b₀)   altura de embebimiento
        // Con l = 0 en la garganta, l > 0 una hoja y l < 0 la otra.
        let phi = 0, trav = [];
        const seed = n => {
          trav = [];
          for (let i = 0; i < 4; i++) trav.push({ l: 1 - i * 0.5, dir: i % 2 ? 1 : -1, p: [] });
        };
        seed();
        return {
          reset() { seed(); phi = 0; },
          step(ctx, w, h, t, acc, P, M) {
            // La métrica es autosimilar en b₀: cambiarlo solo escala la figura y no
            // cambia su forma. Así que b₀ se fija en 1 unidad de mundo y la escala
            // se calcula para llenar el cuadro; el control real es cuánto de las
            // hojas se ve, que sí cambia lo que hay en pantalla.
            const LMAX = P.reach;
            const S = (Math.min(w, h) * 0.40) / Math.sqrt(1 + LMAX * LMAX);
            const b0 = S;
            const tilt = M.in ? 0.12 + (M.y / h) * 0.78 : P.tilt;
            if (M.in) phi = (M.x / w) * TAU; else phi += 0.0035;

            const rOf = l => S * Math.sqrt(1 + l * l);
            const zOf = l => S * Math.asinh(l);
            const ca = Math.cos(phi), sa = Math.sin(phi);
            const kz = Math.cos(tilt), ky = Math.sin(tilt);
            const cx = w / 2, cy = h / 2;
            // Proyección: giro en azimut y luego inclinación de la cámara
            const proj = (l, a) => {
              const r = rOf(l), z = zOf(l);
              const x = r * Math.cos(a), y = r * Math.sin(a);
              const xr = x * ca - y * sa, yr = x * sa + y * ca;
              return [cx + xr, cy + yr * ky - z * kz];
            };

            // Anillos de l constante, dibujados de atrás hacia adelante
            const RINGS = 26, SEG = 40;
            const ls = [];
            for (let i = 0; i <= RINGS; i++) ls.push(-LMAX + (2 * LMAX * i) / RINGS);
            ls.sort((A, B) => zOf(B) - zOf(A));    // los de arriba primero
            ctx.lineWidth = 1;
            for (const l of ls) {
              const near = 1 - Math.min(1, Math.abs(l) / LMAX);
              ctx.strokeStyle = `rgba(${acc},${(0.10 + near * 0.45).toFixed(3)})`;
              ctx.beginPath();
              for (let k = 0; k <= SEG; k++) {
                const pnt = proj(l, (k / SEG) * TAU);
                k ? ctx.lineTo(pnt[0], pnt[1]) : ctx.moveTo(pnt[0], pnt[1]);
              }
              ctx.stroke();
            }
            // Meridianos de ángulo constante
            ctx.strokeStyle = `rgba(${acc},0.14)`;
            for (let k = 0; k < 16; k++) {
              const a = (k / 16) * TAU;
              ctx.beginPath();
              for (let i = 0; i <= 60; i++) {
                const l = -LMAX + (2 * LMAX * i) / 60;
                const pnt = proj(l, a);
                i ? ctx.lineTo(pnt[0], pnt[1]) : ctx.moveTo(pnt[0], pnt[1]);
              }
              ctx.stroke();
            }
            // La garganta: el círculo mínimo, l = 0, radio exactamente b₀
            ctx.strokeStyle = `rgba(${acc},0.95)`; ctx.lineWidth = 1.6;
            ctx.beginPath();
            for (let k = 0; k <= SEG; k++) {
              const pnt = proj(0, (k / SEG) * TAU);
              k ? ctx.lineTo(pnt[0], pnt[1]) : ctx.moveTo(pnt[0], pnt[1]);
            }
            ctx.stroke();

            // Viajeros: avanzan en l a rapidez propia constante y cruzan
            const n = P.travel | 0;
            for (let i = 0; i < n && i < trav.length; i++) {
              const tr = trav[i];
              tr.l += tr.dir * 0.016;
              if (tr.l > LMAX) { tr.l = LMAX; tr.dir = -1; tr.p.length = 0; }
              if (tr.l < -LMAX) { tr.l = -LMAX; tr.dir = 1; tr.p.length = 0; }
              const a = (i / Math.max(1, n)) * TAU + t * 0.25;
              tr.p.push([tr.l, a]);
              if (tr.p.length > 220) tr.p.shift();
              ctx.lineWidth = 1.2;
              for (let k = 1; k < tr.p.length; k++) {
                ctx.strokeStyle = `rgba(${acc},${((k / tr.p.length) * 0.7).toFixed(3)})`;
                const A = proj(tr.p[k - 1][0], tr.p[k - 1][1]), B = proj(tr.p[k][0], tr.p[k][1]);
                ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]); ctx.stroke();
              }
              const pnt = proj(tr.l, a);
              ctx.fillStyle = `rgba(${acc},1)`;
              ctx.beginPath(); ctx.arc(pnt[0], pnt[1], 4.2, 0, TAU); ctx.fill();
            }

            ctx.font = '11px monospace';
            ctx.fillStyle = `rgba(${acc},0.55)`;
            ctx.fillText('r(l) = √(b₀² + l²)      z(l) = b₀·asinh(l/b₀)', 12, h - 28);
            ctx.fillStyle = `rgba(${acc},0.9)`;
            ctx.fillText('b₀ = ' + S.toFixed(0) + 'px    2πb₀ = ' + (TAU * S).toFixed(0) + 'px    l ∈ [−' + LMAX.toFixed(1) + ', ' + LMAX.toFixed(1) + ']', 12, h - 12);
          }
        };
      }
    },