Skip to content

Gargantua

Everything below the next paragraph is one fenced html code block in this document.

Drag to orbit. Scroll to zoom. Let go and it drifts on its own.

<style>
  html, body { margin: 0; padding: 0; background: #000; color: #fff; overflow: hidden; }
  #bh {
    position: relative;
    width: 100%;
    height: 600px;
    height: max(600px, 100vh);
    background: #000;
    overflow: hidden;
  }
  #bh canvas { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; }
  #bh canvas:active { cursor: grabbing; }
  .bh-label {
    position: absolute; bottom: 10px;
    font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
    letter-spacing: .09em; text-transform: uppercase;
    pointer-events: none; user-select: none; white-space: nowrap;
  }
  .bh-left  { left: 14px;  color: rgba(255,255,255,.38); }
  .bh-right { right: 14px; color: rgba(255,255,255,.5); }
  @media (max-width: 620px) {
    .bh-left { display: none; }
    #bh { height: 460px; height: max(460px, 70vh); }
  }
</style>

<div id="bh">
  <div class="bh-label bh-left">Schwarzschild geodesics &middot; traced live in this document</div>
  <div class="bh-label bh-right">drag to orbit &middot; scroll to zoom</div>
</div>

<script type="module">
import * as THREE from '/sandbox/libs/three.module.min.js';

const root = document.getElementById('bh');

const FRAG = `
precision highp float;
uniform vec2  uRes;
uniform float uTime;
uniform vec3  uCamPos, uCamRight, uCamUp, uCamFwd;
uniform float uDetail;

// Units: Schwarzschild radius rs = 1, so the horizon is the unit sphere.
const float DISC_IN  = 2.4;   // ~ISCO for a non-spinning hole (3 rs)
const float DISC_OUT = 9.5;
const int   STEPS    = 260;

float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
float hash13(vec3 p){ p = fract(p*0.1031); p += dot(p, p.zyx+31.32); return fract((p.x+p.y)*p.z); }

float vnoise(vec2 p){
  vec2 i = floor(p), f = fract(p);
  f = f*f*(3.0-2.0*f);
  return mix(mix(hash12(i), hash12(i+vec2(1,0)), f.x),
             mix(hash12(i+vec2(0,1)), hash12(i+vec2(1,1)), f.x), f.y);
}
float fbm(vec2 p){
  float v = 0.0, a = 0.5;
  for(int i=0;i<5;i++){ v += a*vnoise(p); p = p*2.03 + 17.17; a *= 0.5; }
  return v;
}
// Value noise whose y lattice wraps at 'per', so it is seamless all the way around the disk.
float vnoiseP(vec2 p, float per){
  vec2 i = floor(p), f = fract(p);
  f = f*f*(3.0-2.0*f);
  float a = hash12(vec2(i.x,     mod(i.y,     per)));
  float b = hash12(vec2(i.x+1.0, mod(i.y,     per)));
  float c = hash12(vec2(i.x,     mod(i.y+1.0, per)));
  float d = hash12(vec2(i.x+1.0, mod(i.y+1.0, per)));
  return mix(mix(a,b,f.x), mix(c,d,f.x), f.y);
}
float fbmP3(vec2 p, float per){
  float v = 0.0, a = 0.5;
  for(int i=0;i<3;i++){ v += a*vnoiseP(p, per); p = vec2(p.x*2.03+17.17, p.y*2.0); per *= 2.0; a *= 0.5; }
  return v;
}

vec3 diskSample(vec3 hit, vec3 rayDir, float detail){
  float r   = length(hit.xz);
  float phi = atan(hit.z, hit.x);

  // Keplerian shear: inner material laps the outer, so the streaks wind up over time.
  float omega = 1.1/pow(r, 1.5);
  float ang01 = (phi - uTime*omega*2.2)/6.2831853;   // in turns

  float lr = log(r);
  float n  = fbmP3(vec2(lr*3.0, ang01*14.0), 14.0);
  float n2 = fbmP3(vec2(lr*6.5 - 2.2*n, ang01*22.0 + 5.0), 22.0);
  float dens = n*0.82 + n2*0.22*detail;
  dens = pow(clamp(dens, 0.0, 1.0), 1.6);

  float fade = smoothstep(DISC_IN, DISC_IN+0.45, r) * smoothstep(DISC_OUT, DISC_OUT-3.2, r);
  float emis = pow(2.6/r, 2.2);

  // Temperature ramp: white-hot inside, deep orange at the rim.
  float t = clamp((r-DISC_IN)/(DISC_OUT-DISC_IN), 0.0, 1.0);
  vec3 col = mix(vec3(1.00,0.97,0.92),
                 mix(vec3(1.00,0.62,0.25), vec3(0.72,0.20,0.05), smoothstep(0.25,1.0,t)),
                 smoothstep(0.0,0.55,t));

  // Relativistic beaming + gravitational redshift. The approaching side really is
  // this much brighter; Interstellar left it out on purpose, we leave it in.
  vec3 tang = normalize(vec3(-hit.z, 0.0, hit.x));
  float beta = clamp(1.0/sqrt(2.0*max(r-1.0, 0.55)), 0.0, 0.72);
  float dop  = 1.0/(1.0 - beta*dot(tang, -rayDir));
  float boost = dop*dop*dop*dop;
  float gred = sqrt(max(1.0 - 1.0/r, 0.04));
  col *= mix(vec3(1.14,0.54,0.30), vec3(0.80,0.92,1.45), clamp((dop-1.0)*2.6+0.45, 0.0, 1.0));

  // A hot spot orbiting near the inner edge, the flares actually observed
  // around Sgr A*. It rides the same Keplerian clock as the streaks, so you
  // can watch it brighten as it swings onto the approaching side.
  float sOm = (1.1/pow(3.1, 1.5))*2.2;
  vec2 sp = vec2(cos(uTime*sOm), sin(uTime*sOm))*3.1;
  float sd2 = dot(hit.xz-sp, hit.xz-sp);
  float spot = exp(-sd2*1.1)*(0.9 + n);

  return (col*dens + vec3(1.00,0.88,0.68)*spot*1.7) * fade * emis * boost * gred * 2.9;
}

vec3 stars(vec3 dir){
  vec3 col = vec3(0.0);
  for(int L=0; L<2; L++){
    float sc = L==0 ? 130.0 : 300.0;
    vec3 dp = dir*sc, id = floor(dp);
    float rnd = hash13(id);
    if(rnd > 0.995){
      vec3 f = fract(dp)-0.5;
      float d = length(f - (vec3(hash13(id+7.1), hash13(id+3.7), hash13(id+9.3))-0.5)*0.6);
      float s = pow((rnd-0.995)/0.005, 2.0) * smoothstep(0.34, 0.0, d);
      col += mix(vec3(0.75,0.85,1.0), vec3(1.0,0.9,0.75), hash13(id+1.5)) * s * (L==0 ? 1.5 : 0.7);
    }
  }
  float band = exp(-abs(dot(dir, normalize(vec3(0.2,1.0,-0.35))))*4.5);
  col += vec3(0.10,0.13,0.22)*fbm(dir.xy*3.1 + dir.z*2.3)*0.35*band;
  col += vec3(0.25,0.20,0.30)*pow(fbm(dir.xz*2.0+9.0), 2.0)*0.12;
  return col;
}

void main(){
  vec2 uv = (gl_FragCoord.xy - 0.5*uRes)/uRes.y;
  vec3 rd  = normalize(uCamFwd*1.35 + uv.x*uCamRight + uv.y*uCamUp);
  vec3 pos = uCamPos, vel = rd;

  // Conserved angular momentum gives exact Schwarzschild photon bending.
  float h2 = dot(cross(pos, vel), cross(pos, vel));

  // Capture in Schwarzschild is exact: rays with impact parameter below
  // sqrt(27)/2 fall in. How far this pixel sits above that critical value, in
  // units of its own screen-space derivative, is the sub-pixel escape
  // fraction, and scaling the escape radiance by it antialiases the shadow's
  // silhouette analytically. No upscaler can do that after the fact, and the
  // derivative must be taken here, in uniform control flow.
  float bImp = sqrt(h2);
  float escEdge = smoothstep(0.0, 1.2, (bImp - 2.5981)/max(fwidth(bImp), 1e-4));

  // Fade the finest disk octave out where rays pile up, so the streaks stop aliasing.
  float detail = uDetail * clamp(uRes.y/900.0, 0.60, 1.0);

  vec3 col = vec3(0.0);
  float trans = 1.0, minR = 1e4;
  bool captured = false;
  vec3 prev = pos;

  for(int i=0; i<STEPS; i++){
    float r2 = dot(pos,pos), r = sqrt(r2);
    minR = min(minR, r);
    if(r < 1.0){ captured = true; break; }
    if(r > 44.0 && dot(pos, vel) > 0.0) break;

    float dt = clamp(r*0.11, 0.035, 0.30);
    vel += (-1.5*h2*pos/(r2*r2*r))*dt;
    prev = pos;
    pos += vel*dt;

    if(prev.y*pos.y < 0.0){                       // crossed the equatorial plane
      vec3 hit = mix(prev, pos, prev.y/(prev.y - pos.y));
      float hr = length(hit.xz);
      if(hr > DISC_IN && hr < DISC_OUT){
        vec3 dcol = diskSample(hit, normalize(vel), detail);
        col   += trans * dcol;
        trans *= 1.0 - clamp(length(dcol)*0.6, 0.0, 1.0)*0.72;
        if(trans < 0.04) break;
      }
    }
  }

  if(!captured && trans > 0.02){
    // Rays that skimmed the photon sphere and escaped carry disk light from
    // many windings; feather it with the same edge fraction, or it speckles
    // against captured neighbours when the trace buffer is coarse.
    col *= mix(1.0, escEdge, smoothstep(1.65, 1.50, minR));
    col += escEdge * trans * stars(normalize(vel));
    col += escEdge * trans * vec3(1.0,0.62,0.30) * 0.11 * exp(-(minR-1.45)*2.4);   // photon-sphere glow
    col += escEdge * trans * vec3(1.0,0.86,0.62) * 0.55
         * exp(-abs(minR-1.5)*13.0*clamp(uRes.y/900.0, 0.55, 1.0)); // the ring, widened when traced coarse
  }

  // Linear HDR out. Tonemapping, vignette and dither happen in the composite
  // pass, because the bloom needs the raw radiance to know what actually glows.
  gl_FragColor = vec4(col*1.15, 1.0);
}
`;

// --- bloom chain -----------------------------------------------------------
// Real bloom, hand-rolled because the sandbox vets only core three: keep the
// scene in linear HDR, pull out everything brighter than paper white, blur it
// at two scales, and add it back over the tonemapped image. Five tiny passes;
// the geodesic integration still dwarfs all of them.
const VERT = `
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;
const BRIGHT = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
void main(){
  vec3 c = texture2D(uTex, vUv).rgb;
  float l = max(c.r, max(c.g, c.b));
  gl_FragColor = vec4(c*smoothstep(0.85, 1.7, l), 1.0);
}
`;
const BLUR = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uStep;
void main(){
  vec3 c = texture2D(uTex, vUv).rgb*0.227027;
  c += (texture2D(uTex, vUv+uStep*1.3846).rgb + texture2D(uTex, vUv-uStep*1.3846).rgb)*0.3162162;
  c += (texture2D(uTex, vUv+uStep*3.2308).rgb + texture2D(uTex, vUv-uStep*3.2308).rgb)*0.0702703;
  gl_FragColor = vec4(c, 1.0);
}
`;
const COMPOSITE = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform sampler2D uBloomNear;
uniform sampler2D uBloomWide;
uniform vec2 uOutRes;
float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
void main(){
  vec3 col = texture2D(uTex, vUv).rgb;
  col += texture2D(uBloomNear, vUv).rgb*0.50;
  col += texture2D(uBloomWide, vUv).rgb*0.35;
  col = (col*(2.51*col+0.03))/(col*(2.43*col+0.59)+0.14);               // filmic tonemap
  col = pow(clamp(col, 0.0, 1.0), vec3(0.4545));
  vec2 q = (vUv - 0.5)*vec2(uOutRes.x/max(uOutRes.y, 1.0), 1.0);
  col *= 1.0 - 0.34*dot(q,q);                                            // vignette
  col += (hash12(gl_FragCoord.xy)-0.5)/255.0;                            // dither the gradients
  gl_FragColor = vec4(col, 1.0);
}
`;

const uniforms = {
  uRes:      { value: new THREE.Vector2(1,1) },
  uTime:     { value: 0 },
  uDetail:   { value: 1 },
  uCamPos:   { value: new THREE.Vector3() },
  uCamRight: { value: new THREE.Vector3() },
  uCamUp:    { value: new THREE.Vector3() },
  uCamFwd:   { value: new THREE.Vector3() },
};

let renderer;
try {
  renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false });
} catch (err) {
  root.insertAdjacentHTML('afterbegin',
    '<p style="color:#bbb;font:13px system-ui;padding:24px">This scene needs WebGL, which this browser did not provide.</p>');
  throw err;
}
renderer.setClearColor(0x000000, 1);
root.prepend(renderer.domElement);

const scene = new THREE.Scene();
const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2,2),
  new THREE.ShaderMaterial({ fragmentShader: FRAG, uniforms }));
quad.frustumCulled = false;
scene.add(quad);

// Half-float targets carry radiance above 1.0 between passes. Without the
// extension the chain still runs, just with the highlights clipped, so the
// bloom softens instead of breaking.
const hdrType = renderer.extensions.has('EXT_color_buffer_float')
  ? THREE.HalfFloatType : THREE.UnsignedByteType;
const rtOpts = { type: hdrType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false };
const rtScene = new THREE.WebGLRenderTarget(2, 2, rtOpts);
const rtHalfA = new THREE.WebGLRenderTarget(2, 2, rtOpts);
const rtHalfB = new THREE.WebGLRenderTarget(2, 2, rtOpts);
const rtWideA = new THREE.WebGLRenderTarget(2, 2, rtOpts);
const rtWideB = new THREE.WebGLRenderTarget(2, 2, rtOpts);

const sceneMat = quad.material;
const brightMat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: BRIGHT,
  uniforms: { uTex: { value: null } } });
const blurMat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: BLUR,
  uniforms: { uTex: { value: null }, uStep: { value: new THREE.Vector2() } } });
const compositeMat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: COMPOSITE,
  uniforms: { uTex: { value: rtScene.texture }, uBloomNear: { value: rtHalfA.texture },
              uBloomWide: { value: rtWideA.texture }, uOutRes: { value: new THREE.Vector2(1,1) } } });

function pass(mat, target){
  quad.material = mat;
  renderer.setRenderTarget(target);
  renderer.render(scene, cam);
}
function blur(from, to, w, h){
  blurMat.uniforms.uTex.value = from.texture;
  blurMat.uniforms.uStep.value.set(1/w, 0);
  pass(blurMat, to);
  blurMat.uniforms.uTex.value = to.texture;
  blurMat.uniforms.uStep.value.set(0, 1/h);
  pass(blurMat, from);
}

// --- camera orbit ---------------------------------------------------------
// Narrow frames need the camera further back to keep the whole disk on screen.
function fitDistance(){
  const a = Math.max(root.clientWidth, 1)/Math.max(root.clientHeight, 1);
  return 13.5*Math.max(1.0, Math.min(1.62, 1.5/a));
}
let yaw = 0.6, pitch = 0.16, dist = fitDistance();
let tYaw = yaw, tPitch = pitch, tDist = dist;
let dragging = false, lastX = 0, lastY = 0;
let userFramed = false;     // any input stops the automatic re-fit on resize
let pinch0 = 0, dist0 = dist;

const el = renderer.domElement;
el.addEventListener('pointerdown', (e) => {
  dragging = true; userFramed = true; lastX = e.clientX; lastY = e.clientY;
  el.setPointerCapture(e.pointerId);
});
el.addEventListener('pointermove', (e) => {
  if (!dragging) return;
  tYaw   -= (e.clientX - lastX)*0.005;
  tPitch  = Math.max(-1.35, Math.min(1.35, tPitch + (e.clientY - lastY)*0.004));
  lastX = e.clientX; lastY = e.clientY;
});
el.addEventListener('pointerup', () => { dragging = false; });
el.addEventListener('wheel', (e) => {
  e.preventDefault();
  tDist = Math.max(4.5, Math.min(30, tDist*(1 + e.deltaY*0.0012)));
  userFramed = true;
}, { passive: false });
el.addEventListener('touchstart', (e) => {
  if (e.touches.length === 2) {
    pinch0 = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
    dist0 = tDist;
  }
}, { passive: true });
el.addEventListener('touchmove', (e) => {
  if (e.touches.length === 2) {
    const p = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
    tDist = Math.max(4.5, Math.min(30, dist0*pinch0/Math.max(p,1)));
    userFramed = true;
  }
}, { passive: true });

// --- adaptive resolution: hold a smooth frame rate on weak GPUs ------------
// Only the geodesic trace buffer shrinks. The canvas and the composite stay at
// full device resolution, and the HDR result is bilinearly upsampled BEFORE
// tonemapping, so a slow GPU gets a soft image, never a blocky one. On a 3x
// phone the old approach upscaled the finished pixels in the browser, which is
// exactly where a one-pixel photon ring turns into stairsteps.
let scale = 1, frames = 0, acc = 0, last = performance.now();
function applySize(){
  const w = root.clientWidth, h = root.clientHeight;
  const outDpr = Math.min(window.devicePixelRatio || 1, 2.5);
  renderer.setPixelRatio(outDpr);
  renderer.setSize(w, h, false);
  compositeMat.uniforms.uOutRes.value.set(Math.round(w*outDpr), Math.round(h*outDpr));
  const W = Math.max(2, Math.round(w*outDpr*scale)), H = Math.max(2, Math.round(h*outDpr*scale));
  uniforms.uRes.value.set(W, H);
  rtScene.setSize(W, H);
  rtHalfA.setSize(Math.max(2, W>>1), Math.max(2, H>>1));
  rtHalfB.setSize(Math.max(2, W>>1), Math.max(2, H>>1));
  rtWideA.setSize(Math.max(2, W>>2), Math.max(2, H>>2));
  rtWideB.setSize(Math.max(2, W>>2), Math.max(2, H>>2));
}
new ResizeObserver(() => {
  applySize();
  if (!userFramed) { tDist = fitDistance(); }   // re-fit until the reader takes over
}).observe(root);
applySize();

const clock = new THREE.Clock();
let elapsed = 0;
renderer.setAnimationLoop(() => {
  const now = performance.now();
  acc += now - last; last = now;
  if (++frames === 30) {
    const avg = acc/30; acc = 0; frames = 0;
    const next = avg > 26 ? Math.max(0.6, scale - 0.12)
               : avg < 13 ? Math.min(1.0, scale + 0.08) : scale;
    if (next !== scale) { scale = next; applySize(); }
  }

  // Drift and easing are per second, not per frame, so a 120 Hz display and a
  // 60 Hz display move at the same speed. Clamped so a backgrounded tab does
  // not resume with one enormous step.
  const dt = Math.min(clock.getDelta(), 0.1);
  elapsed += dt;

  if (!dragging) tYaw += 0.12*dt;                     // idle drift, ~52 s per turn
  const ease = 1 - Math.exp(-5.0*dt);
  yaw += (tYaw-yaw)*ease; pitch += (tPitch-pitch)*ease; dist += (tDist-dist)*ease;

  const cp = new THREE.Vector3(
    dist*Math.cos(pitch)*Math.sin(yaw),
    dist*Math.sin(pitch),
    dist*Math.cos(pitch)*Math.cos(yaw));
  const fwd = cp.clone().negate().normalize();
  const right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize();

  uniforms.uDetail.value = Math.max(0.5, Math.min(1, 13.5/dist));
  uniforms.uTime.value = elapsed;
  uniforms.uCamPos.value.copy(cp);
  uniforms.uCamRight.value.copy(right);
  uniforms.uCamUp.value.copy(new THREE.Vector3().crossVectors(right, fwd));
  uniforms.uCamFwd.value.copy(fwd);

  pass(sceneMat, rtScene);                              // geodesics, in linear HDR
  brightMat.uniforms.uTex.value = rtScene.texture;
  pass(brightMat, rtHalfA);                             // threshold + half-res downsample
  blur(rtHalfA, rtHalfB, rtHalfA.width, rtHalfA.height); // tight glow
  blurMat.uniforms.uTex.value = rtHalfA.texture;
  blurMat.uniforms.uStep.value.set(1/rtWideA.width, 0);
  pass(blurMat, rtWideB);                               // downsample to quarter + horizontal
  blurMat.uniforms.uTex.value = rtWideB.texture;
  blurMat.uniforms.uStep.value.set(0, 1/rtWideA.height);
  pass(blurMat, rtWideA);                               // vertical, the wide halo
  pass(compositeMat, null);                             // tonemap + bloom + vignette
});
</script>

What you are looking at

The image is traced per pixel by integrating photon paths through Schwarzschild spacetime, with the Schwarzschild radius set to 1. Nothing here is a texture or a model, because the sandbox has no network and nothing can be fetched. Every photon of it is arithmetic.

  • The black disc is not the event horizon. It is the shadow, about 2.6 times larger, because light passing near the hole is captured rather than escaping to your eye.

  • The disk appears to arch over and under the hole. There is only one flat disk, lying in one plane. You are seeing the far side of it bent up over the top and down under the bottom by gravity, along with a thin second image of it squeezed against the shadow.

  • One side is far brighter than the other. The disk orbits at a large fraction of light speed, and the approaching side is beamed toward you and blueshifted while the receding side is dimmed and reddened. Interstellar deliberately left this out because Nolan found the asymmetry distracting. It is left in here, because it is what you would actually see.

  • A thin bright ring hugs the edge of the shadow. Photons that grazed the photon sphere at 1.5 Schwarzschild radii circled the hole before escaping to you. That ring is the stack of increasingly folded images of the whole sky.

  • A bright knot laps the inner edge. Flares like it are really observed orbiting Sgr A*, the black hole at the centre of our galaxy. Watch it surge as it swings onto the approaching side, where beaming amplifies it.

  • The streaks wind up as you watch. Orbital speed follows Kepler, so inner material laps outer material and shears any structure into spirals.

How it runs

The block imports three from the sandbox's vetted library path and uses it for the renderer, a fullscreen quad, and uniform plumbing. The physics lives in a GLSL fragment shader, because lensing bends light per pixel and cannot be rasterized from a mesh.

The glow is real bloom, not a sprite: the scene is kept in linear HDR off screen, everything brighter than paper white is blurred at two scales and added back, and only then is the image tonemapped. The whole chain is five small passes hand-rolled on render targets, because the sandbox vets core three and nothing else.

It watches its own frame time and adapts, but only the geodesic tracing buffer ever shrinks: the composite runs at full device resolution and upsamples the HDR before tonemapping, so a slow GPU gets a softer image, never a blocky one. The shadow's edge cannot be saved by any upscaler, because capture is a yes or no per ray, so it is antialiased analytically instead: capture in Schwarzschild happens exactly below a critical impact parameter, and each pixel scales its escape radiance by how far it sits above that threshold in units of its own screen-space derivative. There is no network access of any kind: no CDN, no telemetry, no fonts. Copy the source out of the block and it will run anywhere with WebGL.