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.
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);
}
};
}
},