Not a diagram of a wormhole: the view through one. Every pixel is a light ray traced backwards through the metric, and the ones that make it to the other side bring back a different sky.
EXP-014
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 rim. Right at the edge of the disc the near sky is smeared into a thin ring: those rays passed close to the throat, wound part way around it and came back. Widen the throat and the other sky takes over the frame; pull the camera back and it shrinks to a coin. The readout tells you what fraction of your field of view is now somewhere else.
The maths
ds² = −dt² + dl² + (b₀² + l²)dφ²
The Ellis metric, the simplest traversable wormhole. l runs through the throat instead of stopping at it, so light can cross.
dl/dφ = ±(r²/b)·√(1 − b²/r²)
The null geodesic equation, with b the impact parameter and r² = b₀² + l². Integrating it is the entire renderer.
b < b₀ ⟹ crosses
The condition that decides everything. Since r never drops below b₀, a ray with a smaller impact parameter has no turning point and must come out the other side. Everything else bends around and returns.
How it is built
A full per-pixel trace would be far too slow, but the camera sits on the axis so the deflection depends only on the angle from it. That makes the problem one-dimensional: 512 geodesics are integrated once into a lookup table, and every pixel is then a table read. The integration is done with respect to φ rather than l, which removes the square-root divergence at the turning point. Both skies are procedural and sampled by the same function, so what separates them is where the ray came from, not how they are drawn.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
Integrate in phi, not in l
let l = l0, phi = 0, dir = -1; // arranca cayendo hacia la garganta
const dphi = 0.004;
let guard = 0;
while (guard++ < 6000) {
const r2 = b0 * b0 + l * l;
const s = 1 - (b * b) / r2;
if (s <= 0) { dir = -dir; l += dir * 0.001; continue; } // punto de retorno
const dl = dir * (r2 / Math.max(b, 1e-6)) * Math.sqrt(s);
l += dl * dphi; phi += dphi;
if (l > LINF || l < -LINF) break;
Written in terms of l the integrand blows up at the turning point where the square root vanishes. Stepping in phi instead makes dl/dphi go smoothly to zero there, so plain Euler with a fine step is stable and no special case is needed.
02
Which sky the ray came from
side[i] = l < 0 ? 1 : 0;
t[i] = phi;
After integration the sign of l says which side the ray escaped to, and the accumulated phi is the direction it came from out there. Two numbers per ray, and that is the whole lookup table.
The camera is on the axis, so deflection depends only on the angle from it and the problem collapses to one dimension. Without that symmetry this would need tens of thousands of integrations per frame instead of 512 once.
Why it matters
This is how the Interstellar wormhole was made: Kip Thorne wrote the equations, the effects team integrated them per pixel, and the result was a published paper as well as a film. The point is that a wormhole has no appearance of its own. You never see the tunnel. You see the other place, wrapped into a sphere by the geometry between you and it.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · shortcut
{ id: 'shortcut',
name: L('Looking through', 'Mirar a través'),
note: L('Ray-traced view into an Ellis wormhole. Two skies in one image.',
'Vista trazada por rayos hacia un agujero de Ellis. Dos cielos en una imagen.'),
params: [
{ key: 'b0', label: L('Throat radius b₀', 'Radio de garganta b₀'), min: 0.2, max: 3, step: 0.05, def: 1 },
{ key: 'dist', label: L('Camera distance', 'Distancia de la cámara'), min: 1.5, max: 14, step: 0.25, def: 5 },
{ key: 'fov', label: L('Field of view', 'Campo de visión'), min: 30, max: 140, step: 2, def: 90, unit: '°' }
],
make() {
// Métrica de Ellis (Morris-Thorne con b(r) = b₀²/r):
// ds² = −dt² + dl² + (b₀² + l²)dφ²
// Un rayo con parámetro de impacto b cumple
// dl/dφ = ±(r²/b)·√(1 − b²/r²), r² = b₀² + l²
// Integrando EN φ la raíz no diverge en el punto de retorno: dl/dφ → 0
// suavemente, así que basta Euler con paso fino.
const N = 512; // rayos de la tabla
let lut = null, key = '', img = null, off = null, ikey = '';
function buildLUT(b0, l0, fovRad) {
const r0 = Math.sqrt(b0 * b0 + l0 * l0);
const t = new Float32Array(N); // ángulo asintótico Θ
const side = new Uint8Array(N); // 0 = mismo lado, 1 = el otro
const LINF = 260;
for (let i = 0; i < N; i++) {
const psi = (i / (N - 1)) * (fovRad / 2);
const b = r0 * Math.sin(psi);
let l = l0, phi = 0, dir = -1; // arranca cayendo hacia la garganta
const dphi = 0.004;
let guard = 0;
while (guard++ < 6000) {
const r2 = b0 * b0 + l * l;
const s = 1 - (b * b) / r2;
if (s <= 0) { dir = -dir; l += dir * 0.001; continue; } // punto de retorno
const dl = dir * (r2 / Math.max(b, 1e-6)) * Math.sqrt(s);
l += dl * dphi; phi += dphi;
if (l > LINF || l < -LINF) break;
}
side[i] = l < 0 ? 1 : 0;
t[i] = phi;
}
return { t, side, r0 };
}
// Cielos procedurales. Las estrellas se generan por celda con una
// posición aleatoria dentro de ella y caída suave, así se leen como
// puntos y no como bloques del buffer.
function sky(which, th, az, rgb, px, o) {
const hash = (a, b) => {
const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
return s - Math.floor(s);
};
const K = which ? 34 : 22;
const u = th * K, v = az * K * 0.5;
let star = 0;
for (let du = -1; du <= 0; du++) {
for (let dv = -1; dv <= 0; dv++) {
const cu = Math.floor(u) + du, cv = Math.floor(v) + dv;
const h1 = hash(cu, cv);
if (h1 < (which ? 0.28 : 0.16)) continue; // celda sin estrella
const sx = cu + hash(cu + 3.3, cv), sy = cv + hash(cu, cv + 7.7);
const d = Math.hypot(u - sx, v - sy);
const mag = 0.35 + hash(cv, cu) * 0.65;
star = Math.max(star, Math.max(0, 1 - d / 0.42) * mag);
}
}
star = Math.pow(star, 1.7);
// Retícula de coordenadas: distinta densidad a cada lado
const gA = Math.abs(((th * (which ? 7 : 4)) % 1) - 0.5);
const gB = Math.abs(((az * (which ? 7 : 4)) % 1) - 0.5);
const grid = Math.max(0, 0.5 - Math.min(gA, gB)) * (which ? 0.20 : 0.13);
if (which) { // el otro cielo: frío, pálido
px[o] = Math.min(255, 205 * star + 110 * grid);
px[o+1] = Math.min(255, 222 * star + 130 * grid);
px[o+2] = Math.min(255, 255 * star + 165 * grid);
} else { // el propio: el color del tema
px[o] = Math.min(255, rgb[0] * star + rgb[0] * grid);
px[o+1] = Math.min(255, rgb[1] * star + rgb[1] * grid);
px[o+2] = Math.min(255, rgb[2] * star + rgb[2] * grid);
}
px[o+3] = 255;
}
return {
reset() { key = ''; ikey = ''; },
step(ctx, w, h, t, acc, P, M) {
const b0 = P.b0, l0 = P.dist, fov = P.fov * Math.PI / 180;
const spin = M.in ? (M.x / w) * TAU : t * 0.05;
const RES = 300;
const k = [b0.toFixed(2), l0.toFixed(2), P.fov].join('|');
if (k !== key) { key = k; lut = buildLUT(b0, l0, fov); ikey = ''; }
const ik = [k, acc].join('|');
if (ik !== ikey) {
ikey = ik;
if (!img || img.width !== RES) {
img = ctx.createImageData(RES, RES);
off = document.createElement('canvas'); off.width = off.height = RES;
}
const rgb = acc.split(',').map(Number), px = img.data;
const half = RES / 2, tanH = Math.tan(fov / 2);
for (let j = 0; j < RES; j++) {
const dy = (j - half) / half;
for (let i = 0; i < RES; i++) {
const dx = (i - half) / half;
const rad = Math.hypot(dx, dy);
const o = (j * RES + i) * 4;
// ángulo del rayo respecto al eje óptico
const psi = Math.atan(rad * tanH);
const idx = Math.min(N - 1, Math.round((psi / (fov / 2)) * (N - 1)));
const az = Math.atan2(dy, dx);
sky(lut.side[idx], lut.t[idx], az, rgb, px, o);
}
}
off.getContext('2d').putImageData(img, 0, 0);
}
// El giro se aplica al dibujar. Recalcular el buffer por cada frame de
// rotación costaría 90.000 lecturas de tabla que no cambian nada.
const S = Math.max(w, h) * 1.45;
ctx.save();
ctx.translate(w / 2, h / 2); ctx.rotate(spin);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, -S / 2, -S / 2, S, S);
ctx.restore();
// Borde de la boca: el último rayo que aún cruza al otro lado
let edge = 0;
for (let i = 0; i < N; i++) if (lut.side[i]) edge = i;
const psiE = ((edge + 0.5) / (N - 1)) * (fov / 2);
const rE = Math.tan(psiE) / Math.tan(fov / 2);
ctx.strokeStyle = `rgba(${acc},0.5)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(w / 2, h / 2, (rE * S) / 2, 0, TAU); ctx.stroke();
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('dl/dφ = ±(r²/b)·√(1 − b²/r²) r² = b₀² + l²', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
const frac = lut.side.reduce((a, b) => a + b, 0) / N;
ctx.fillText('b₀ = ' + b0.toFixed(2) + ' camera l = ' + l0.toFixed(1) +
' other sky covers ' + (frac * 100).toFixed(0) + '% of the view', 12, h - 12);
}
};
}
},