Showcase

The black hole, built by Gemini 4 Argon

Hostedvia DaBenchOpenRouter
Running live in your browserNew tab
Speed
88tok/s
First token included
Written
30.2Ktokens
Time to build
5m 45s
Cost to build
$0.15
Show the code the model wrote
HTML34,954 characters
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Gargantua — Schwarzschild Black Hole Gravitational Lensing</title>
<style>
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }

  html, body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #000000;
    color: #e8e4dc;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    user-select: none;
    -webkit-user-select: none;
  }

  #glCanvas {
    position: fixed;
     inset: 0;
    width: 100vw;
    height: 100vh;
    display: block;
    cursor: grab;
    touch-action: none;
    background: #000000;
  }

  #glCanvas:active {
    cursor: grabbing;
  }

  /* Subtle HUD Overlay */
  .hud {
    position: fixed;
    top: 20px;
    right: 20px;
    width: 310px;
    background: rgba(10, 10, 14, 0.72);
    backdrop-filter: blur(14px);
    -webkit-backdrop-filter: blur(14px);
    border: 1px solid rgba(255, 190, 120, 0.18);
    border-radius: 12px;
    padding: 16px 18px;
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.75);
    z-index: 10;
    transition: opacity 0.25s ease, transform 0.25s ease;
  }

  .hud.collapsed .hud-body {
    display: none;
  }

  .hud-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 12px;
    padding-bottom: 10px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  }

  .hud.collapsed .hud-header {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
  }

  .title-wrap {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  .hud-title {
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: #ffcf96;
  }

  .hud-subtitle {
    font-size: 10px;
    letter-spacing: 0.06em;
    color: rgba(232, 228, 220, 0.55);
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  }

  .toggle-btn {
    background: rgba(255, 255, 255, 0.06);
    border: 1px solid rgba(255, 255, 255, 0.12);
    color: #e8e4dc;
    border-radius: 6px;
    width: 26px;
    height: 26px;
    font-size: 13px;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 0.15s ease, border-color 0.15s ease;
  }

  .toggle-btn:hover {
    background: rgba(255, 190, 120, 0.16);
    border-color: rgba(255, 190, 120, 0.4);
  }

  .control-group {
    margin-bottom: 13px;
  }

  .control-group:last-child {
    margin-bottom: 0;
  }

  .control-label {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.04em;
    color: rgba(232, 228, 220, 0.85);
    margin-bottom: 6px;
  }

  .control-value {
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
    font-size: 11px;
    color: #ffbe78;
  }

  input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    width: 100%;
    height: 5px;
    background: rgba(255, 255, 255, 0.12);
    border-radius: 3px;
    outline: none;
    cursor: pointer;
  }

  input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: #ffcf96;
    border: 2px solid #1a1410;
    box-shadow: 0 0 10px rgba(255, 175, 90, 0.7);
    cursor: pointer;
    transition: transform 0.1s ease;
  }

  input[type="range"]::-webkit-slider-thumb:hover {
    transform: scale(1.15);
  }

  input[type="range"]::-moz-range-thumb {
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: #ffcf96;
    border: 2px solid #1a1410;
    box-shadow: 0 0 10px rgba(255, 175, 90, 0.7);
    cursor: pointer;
  }

  .preset-row {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 5px;
    margin-top: 6px;
  }

  .preset-btn {
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid rgba(255, 255, 255, 0.11);
    color: rgba(232, 228, 220, 0.82);
    font-size: 10px;
    padding: 5px 4px;
    border-radius: 5px;
    cursor: pointer;
    text-align: center;
    transition: all 0.15s ease;
    font-weight: 500;
  }

  .preset-btn:hover {
    background: rgba(255, 190, 120, 0.15);
    border-color: rgba(255, 190, 120, 0.45);
    color: #ffffff;
  }

  .preset-btn.active {
    background: rgba(255, 175, 90, 0.24);
    border-color: #ffbe78;
    color: #ffdfba;
  }

  .telemetry {
    margin-top: 12px;
    padding-top: 10px;
    border-top: 1px solid rgba(255, 255, 255, 0.08);
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 6px 10px;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
    font-size: 10px;
    color: rgba(232, 228, 220, 0.58);
  }

  .telemetry span b {
    color: rgba(255, 210, 160, 0.9);
    font-weight: 600;
  }

  .hint-bar {
    position: fixed;
    bottom: 18px;
    left: 22px;
    font-size: 11px;
    letter-spacing: 0.05em;
    color: rgba(232, 228, 220, 0.5);
    background: rgba(8, 8, 12, 0.55);
    padding: 7px 12px;
    border-radius: 20px;
    border: 1px solid rgba(255, 255, 255, 0.08);
    pointer-events: none;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
  }
</style>
</head>
<body>

<canvas id="glCanvas"></canvas>

<aside class="hud" id="hudPanel">
  <div class="hud-header">
    <div class="title-wrap">
      <div class="hud-title">Gargantua</div>
      <div class="hud-subtitle">Schwarzschild Null Geodesics</div>
    </div>
    <button class="toggle-btn" id="collapseBtn" title="Toggle Controls">−</button>
  </div>

  <div class="hud-body">
    <div class="control-group">
      <div class="control-label">
        <span>Exposure</span>
        <span class="control-value" id="exposureVal">1.15 EV</span>
      </div>
      <input type="range" id="exposureSlider" min="0.25" max="2.80" step="0.01" value="1.15" />
    </div>

    <div class="control-group">
      <div class="control-label">
        <span>Camera Tilt (Elevation)</span>
        <span class="control-value" id="tiltVal">8.5°</span>
      </div>
      <input type="range" id="tiltSlider" min="-15.0" max="90.0" step="0.1" value="8.5" />
      <div class="preset-row">
        <button class="preset-btn" data-tilt="1.2">Edge-On</button>
        <button class="preset-btn active" data-tilt="8.5">Gargantua</button>
        <button class="preset-btn" data-tilt="32.0">Oblique</button>
        <button class="preset-btn" data-tilt="90.0">Face-On</button>
      </div>
    </div>

    <div class="control-group">
      <div class="control-label">
        <span>Doppler Beaming</span>
        <span class="control-value" id="dopplerVal">100%</span>
      </div>
      <input type="range" id="dopplerSlider" min="0.0" max="1.5" step="0.02" value="1.0" />
    </div>

    <div class="control-group">
      <div class="control-label">
        <span>Internal Resolution Scale</span>
        <span class="control-value" id="scaleVal">0.60x</span>
      </div>
      <input type="range" id="scaleSlider" min="0.35" max="1.0" step="0.05" value="0.60" />
    </div>

    <div class="telemetry">
      <span>FPS: <b id="fpsReadout">60</b></span>
      <span>Buffer: <b id="resReadout">--</b></span>
      <span>r<sub>ph</sub>: <b>1.50 r<sub>s</sub></b></span>
      <span>b<sub>crit</sub>: <b>2.598 r<sub>s</sub></b></span>
    </div>
  </div>
</aside>

<div class="hint-bar">
  Drag vertically: Tilt (Edge-On ↔ Face-On) &nbsp;•&nbsp; Drag horizontally: Orbit &nbsp;•&nbsp; Scroll: Zoom
</div>

<script>
(function() {
  "use strict";

  const canvas = document.getElementById("glCanvas");
  const hudPanel = document.getElementById("hudPanel");
  const collapseBtn = document.getElementById("collapseBtn");
  const exposureSlider = document.getElementById("exposureSlider");
  const exposureVal = document.getElementById("exposureVal");
  const tiltSlider = document.getElementById("tiltSlider");
  const tiltVal = document.getElementById("tiltVal");
  const dopplerSlider = document.getElementById("dopplerSlider");
  const dopplerVal = document.getElementById("dopplerVal");
  const scaleSlider = document.getElementById("scaleSlider");
  const scaleVal = document.getElementById("scaleVal");
  const fpsReadout = document.getElementById("fpsReadout");
  const resReadout = document.getElementById("resReadout");
  const presetBtns = document.querySelectorAll(".preset-btn");

  // Camera and simulation state (pre-allocated scalars, zero per-frame allocations)
  let camElevationDeg = 8.5;
  let targetElevationDeg = 8.5;
  let camAzimuth = 0.35;
  let targetAzimuth = 0.35;
  let camDistance = 20.5;
  let targetDistance = 20.5;

  let exposure = 1.15;
  let dopplerStrength = 1.0;
  let resScale = 0.60;

  let fullWidth = 0;
  let fullHeight = 0;
  let internalWidth = 0;
  let internalHeight = 0;

  // Pointer interaction state
  let isDragging = false;
  let lastPointerX = 0;
  let lastPointerY = 0;
  let pinchStartDist = 0;
  let pinchStartCamDist = 20.5;

  collapseBtn.addEventListener("click", function() {
    const collapsed = hudPanel.classList.toggle("collapsed");
    collapseBtn.textContent = collapsed ? "+" : "−";
  });

  exposureSlider.addEventListener("input", function() {
    exposure = parseFloat(exposureSlider.value);
    exposureVal.textContent = exposure.toFixed(2) + " EV";
  });

  function updatePresetButtons(deg) {
    for (let i = 0; i < presetBtns.length; i++) {
      const btn = presetBtns[i];
      const pDeg = parseFloat(btn.getAttribute("data-tilt"));
      if (Math.abs(pDeg - deg) < 1.2) {
        btn.classList.add("active");
      } else {
        btn.classList.remove("active");
      }
    }
  }

  tiltSlider.addEventListener("input", function() {
    targetElevationDeg = parseFloat(tiltSlider.value);
    camElevationDeg = targetElevationDeg;
    tiltVal.textContent = targetElevationDeg.toFixed(1) + "°";
    updatePresetButtons(targetElevationDeg);
  });

  for (let i = 0; i < presetBtns.length; i++) {
    presetBtns[i].addEventListener("click", function() {
      const deg = parseFloat(this.getAttribute("data-tilt"));
      targetElevationDeg = deg;
      tiltSlider.value = deg.toFixed(1);
      tiltVal.textContent = deg.toFixed(1) + "°";
      updatePresetButtons(deg);
    });
  }

  dopplerSlider.addEventListener("input", function() {
    dopplerStrength = parseFloat(dopplerSlider.value);
    dopplerVal.textContent = Math.round(dopplerStrength * 100) + "%";
  });

  scaleSlider.addEventListener("input", function() {
    resScale = parseFloat(scaleSlider.value);
    scaleVal.textContent = resScale.toFixed(2) + "x";
    resizeBuffers();
  });

  // Mouse & Touch dragging on canvas
  canvas.addEventListener("pointerdown", function(e) {
    if (e.button !== 0 && e.pointerType === "mouse") return;
    isDragging = true;
    lastPointerX = e.clientX;
    lastPointerY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  });

  canvas.addEventListener("pointermove", function(e) {
    if (!isDragging) return;
    const dx = e.clientX - lastPointerX;
    const dy = e.clientY - lastPointerY;
    lastPointerX = e.clientX;
    lastPointerY = e.clientY;

    // Horizontal drag orbits azimuth; Vertical drag tilts elevation from edge-on to face-on
    targetAzimuth -= dx * 0.0055;
    camAzimuth = targetAzimuth;

    targetElevationDeg = Math.max(-15.0, Math.min(90.0, targetElevationDeg + dy * 0.22));
    camElevationDeg = targetElevationDeg;

    tiltSlider.value = targetElevationDeg.toFixed(1);
    tiltVal.textContent = targetElevationDeg.toFixed(1) + "°";
    updatePresetButtons(targetElevationDeg);
  });

  function endDrag(e) {
    if (!isDragging) return;
    isDragging = false;
    try {
      canvas.releasePointerCapture(e.pointerId);
    } catch (_) {}
  }

  canvas.addEventListener("pointerup", endDrag);
  canvas.addEventListener("pointercancel", endDrag);

  canvas.addEventListener("wheel", function(e) {
    e.preventDefault();
    targetDistance = Math.max(11.5, Math.min(36.0, targetDistance + e.deltaY * 0.012));
  }, { passive: false });

  canvas.addEventListener("touchstart", function(e) {
    if (e.touches.length === 2) {
      const dx = e.touches[0].clientX - e.touches[1].clientX;
      const dy = e.touches[0].clientY - e.touches[1].clientY;
      pinchStartDist = Math.hypot(dx, dy);
      pinchStartCamDist = targetDistance;
    }
  }, { passive: true });

  canvas.addEventListener("touchmove", function(e) {
    if (e.touches.length === 2 && pinchStartDist > 0) {
      const dx = e.touches[0].clientX - e.touches[1].clientX;
      const dy = e.touches[0].clientY - e.touches[1].clientY;
      const dist = Math.hypot(dx, dy);
      const ratio = pinchStartDist / Math.max(dist, 10);
      targetDistance = Math.max(11.5, Math.min(36.0, pinchStartCamDist * ratio));
    }
  }, { passive: true });

  // ============================================================================
  // WEBGL INITIALIZATION (with reduced-resolution FBO + Upscale pass)
  // ============================================================================
  const gl = canvas.getContext("webgl", {
    alpha: false,
    depth: false,
    stencil: false,
    antialias: false,
    preserveDrawingBuffer: false,
    powerPreference: "high-performance"
  });

  // Fullscreen triangle vertex shader (shared by raymarch pass and upscale pass)
  const vertexShaderSource = `
    attribute vec2 aPos;
    varying vec2 vUv;
    void main() {
      vUv = aPos * 0.5 + 0.5;
      gl_Position = vec4(aPos, 0.0, 1.0);
    }
  `;

  // Per-pixel Schwarzschild Gravitational Lensing Fragment Shader
  const blackHoleFragSource = `
    precision highp float;
    varying vec2 vUv;

    uniform vec2 uResolution;
    uniform float uTime;
    uniform vec3 uCamPos;
    uniform vec3 uCamForward;
    uniform vec3 uCamRight;
    uniform vec3 uCamUp;
    uniform float uExposure;
    uniform float uDoppler;

    // Physical constants in units of Schwarzschild radius r_s = 2GM/c^2 = 1.0
    const float RS = 1.0;
    const float R_PHOTON = 1.5;
    const float B_CRIT = 2.59807621; // 3 * sqrt(3) / 2
    const float R_IN = 2.62;         // Inner edge of hot accretion disk
    const float R_OUT = 13.8;        // Outer edge of accretion disk

    // Fast hash & value noise for seamless disk filaments
    float hash21(vec2 p) {
      p = fract(p * vec2(234.34, 435.345));
      p += dot(p, p + 34.23);
      return fract(p.x * p.y);
    }

    float noise2D(vec2 p) {
      vec2 i = floor(p);
      vec2 f = fract(p);
      f = f * f * (3.0 - 2.0 * f);
      float a = hash21(i);
      float b = hash21(i + vec2(1.0, 0.0));
      float c = hash21(i + vec2(0.0, 1.0));
      float d = hash21(i + vec2(1.0, 1.0));
      return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
    }

    float fbm(vec2 p) {
      float v = 0.0;
      float amp = 0.5;
      mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
      for (int i = 0; i < 4; i++) {
        v += amp * noise2D(p);
        p = rot * p * 2.03 + vec2(17.1, 31.7);
        amp *= 0.5;
      }
      return v;
    }

    // Blackbody temperature to RGB: deep crimson -> orange -> gold -> incandescent white
    vec3 blackbodyColor(float temp) {
      float t = clamp(temp, 0.05, 2.2);
      vec3 col;
      col.r = smoothstep(0.0, 0.32, t) * 1.15;
      col.g = smoothstep(0.18, 0.78, t) * 0.96;
      col.b = smoothstep(0.48, 1.25, t) * 0.92 + smoothstep(1.1, 2.0, t) * 0.35;
      // Boost warm Gargantua orange-gold midtones
      col += vec3(0.45, 0.16, 0.02) * exp(-4.5 * (t - 0.52) * (t - 0.52));
      return max(col, vec3(0.0));
    }

    // Evaluate accretion disk emission & opacity at equatorial plane crossing (y = 0)
    void shadeAccretionDisk(
      vec3 hitP,
      vec3 hitV,
      float hitR,
      inout vec3 accumColor,
      inout float accumAlpha
    ) {
      float u = (hitR - R_IN) / (R_OUT - R_IN);
      if (u <= 0.0 || u >= 1.0) return;

      // Radial temperature and density profile
      float innerRamp = smoothstep(0.0, 0.038, u);
      float outerFade = 1.0 - smoothstep(0.68, 1.0, u);
      float radialFalloff = pow(max(u + 0.025, 0.015), -0.82);

      // Seamless Keplerian differential spiral coordinates
      float phi = atan(hitP.z, hitP.x);
      float omega = 2.6 * pow(hitR / R_IN, -1.35);
      float spiralPhase = phi + 5.2 * log(hitR) - uTime * omega;
      vec2 spiralCircle = vec2(cos(spiralPhase), sin(spiralPhase));

      // Multi-scale sheared azimuthal rings + spiral turbulence
      float ringsFine = 0.5 + 0.5 * sin(hitR * 16.5 + fbm(spiralCircle * 3.2 + hitR * 0.4) * 5.0);
      float ringsBroad = 0.5 + 0.5 * cos(hitR * 6.2 - fbm(spiralCircle * 1.8) * 3.5);
      float spiralWisp = fbm(spiralCircle * (2.4 + hitR * 0.35) + vec2(hitR * 1.7, -uTime * 0.25));

      float filament = mix(ringsBroad, ringsFine, 0.55) * (0.45 + 0.85 * spiralWisp);
      // Hot dense inner core transitions to fibrous outer filaments
      float structure = mix(filament, 0.92, exp(-u * 7.5));

      // Relativistic Keplerian orbital velocity in equatorial plane
      // Counter-clockwise orbit so left side of screen approaches observer
      vec3 gasDir = normalize(vec3(hitP.z, 0.0, -hitP.x));
      float beta = clamp(sqrt(0.5 * RS / max(hitR - RS, 0.65)), 0.0, 0.56) * uDoppler;
      float gamma = inversesqrt(max(1.0 - beta * beta, 0.1));

      // Relativistic Doppler shift using local bent ray direction
      float cosTheta = dot(gasDir, -hitV);
      float doppler = 1.0 / (gamma * (1.0 - beta * cosTheta));
      float gravRedshift = sqrt(max(1.0 - RS / hitR, 0.15));
      float gFactor = clamp(doppler * gravRedshift, 0.28, 2.45);

      // Relativistic beaming intensity modulation (I ~ g^3.3)
      float beaming = pow(gFactor, 3.25);

      // Temperature is orange-white hot at inner edge, cooling outward, shifted by gFactor
      float baseTemp = mix(1.22, 0.22, pow(u, 0.52));
      float observedTemp = baseTemp * pow(gFactor, 0.68) * (0.85 + 0.25 * structure);

      vec3 col = blackbodyColor(observedTemp);
      float intensity = innerRamp * outerFade * radialFalloff * structure * beaming * 2.55;

      // Extra incandescent inner rim boost right near ISCO
      float innerRim = exp(-u * 19.0) * innerRamp * beaming * 1.35;
      col += vec3(1.0, 0.93, 0.82) * innerRim;

      vec3 emission = col * intensity;
      float opacity = clamp(innerRamp * outerFade * (0.35 + 0.65 * structure) * pow(1.0 - u * 0.7, 0.6), 0.0, 0.96);
      // Inner disk is optically dense
      opacity = mix(opacity, 0.98, exp(-u * 9.0) * innerRamp);

      accumColor += (1.0 - accumAlpha) * emission * opacity;
      accumAlpha += (1.0 - accumAlpha) * opacity;
    }

    // Lensed background starfield that smears into tangential Einstein arcs near the shadow edge
    vec3 sampleLensedStarfield(vec3 rayDir, vec3 initDir, vec3 orbitalNormal, float b) {
      vec3 stars = vec3(0.0);

      // Tangential and radial basis in the photon's gravitational deflection plane
      vec3 tanDir = normalize(orbitalNormal);
      vec3 radDir = normalize(cross(rayDir, tanDir));

      // Near b -> B_CRIT, gravitational lensing strongly compresses radial sky angle
      // and stretches stars tangentially into arcs around the shadow edge
      float db = max(b - B_CRIT, 0.001);
      float lensStretch = 1.0 + 2.6 / (db + 0.045);
      float magnification = 1.0 + 0.85 / (db + 0.08);

      // 1. Landmark bright background stars (creates clear Einstein arcs when passing behind BH)
      for (int i = 0; i < 16; i++) {
        float fi = float(i);
        // Deterministic golden-ratio spherical distribution + concentrated stars near optical axis
        float z = 1.0 - 2.0 * fract(fi * 0.6180339 + 0.13);
        float rxy = sqrt(max(1.0 - z * z, 0.0));
        float lon = fi * 2.3999632 + 0.7;
        vec3 starPos = normalize(vec3(rxy * cos(lon), z * 0.65, rxy * sin(lon)));

        // Measure angular separation decomposed into radial deflection vs tangential arc direction
        vec3 diff = rayDir - starPos;
        float dRad = dot(diff, radDir) * (1.0 / clamp(lensStretch * 0.55, 1.0, 7.0));
        float dTan = dot(diff, tanDir) * (1.0 / clamp(1.0 + (lensStretch - 1.0) * 0.18, 1.0, 2.2));
        float dOrtho = length(diff - dot(diff, radDir) * radDir - dot(diff, tanDir) * tanDir);

        float dist2 = dRad * dRad + dTan * dTan + dOrtho * dOrtho;
        float core = exp(-dist2 * 9500.0);
        float halo = exp(-dist2 * 1100.0) * 0.28;

        vec3 starTint = mix(vec3(1.0, 0.82, 0.62), vec3(0.72, 0.86, 1.0), fract(fi * 0.37));
        stars += starTint * (core * 1.9 + halo) * min(magnification, 4.5);
      }

      // 2. Dense procedural background starfield with tangential shear near shadow
      vec3 warpedDir = normalize(rayDir + radDir * (0.06 / (db + 0.06)));
      float uSph = atan(warpedDir.z, warpedDir.x) * 38.0;
      float vSph = asin(clamp(warpedDir.y, -1.0, 1.0)) * 38.0;

      // Compress radial grid frequency near shadow so procedural stars smear into arcs
      vec2 uvGrid = vec2(uSph, vSph);
      vec2 cell = floor(uvGrid);
      vec2 local = fract(uvGrid) - 0.5;

      float rnd = hash21(cell);
      if (rnd > 0.76) {
        vec2 starCenter = (vec2(hash21(cell + 13.7), hash21(cell + 29.3)) - 0.5) * 0.65;
        vec2 d = local - starCenter;
        // Stretch star footprint along tangential direction near b -> B_CRIT
        vec2 arcAxis = normalize(vec2(tanDir.x + tanDir.z, tanDir.y) + vec2(1e-4));
        float alongArc = dot(d, arcAxis);
        float perpArc = length(d - alongArc * arcAxis);
        float stretch = clamp(1.0 + 2.4 * exp(-db * 2.2), 1.0, 3.4);
        float r2 = (alongArc / stretch) * (alongArc / stretch) + perpArc * perpArc;
        float size = mix(45.0, 140.0, fract(rnd * 17.3));
        float brightness = exp(-r2 * size) * smoothstep(0.76, 0.99, rnd);
        vec3 sCol = mix(vec3(1.0, 0.78, 0.58), vec3(0.76, 0.88, 1.0), fract(rnd * 53.1));
        stars += sCol * brightness * 1.45 * min(magnification, 3.2);
      }

      // Second finer star layer
      vec2 uvGrid2 = uvGrid * 2.15 + vec2(19.4, 7.2);
      vec2 cell2 = floor(uvGrid2);
      vec2 local2 = fract(uvGrid2) - 0.5;
      float rnd2 = hash21(cell2);
      if (rnd2 > 0.82) {
        float r2 = dot(local2, local2);
        float b2 = exp(-r2 * 95.0) * smoothstep(0.82, 0.995, rnd2);
        stars += vec3(0.88, 0.91, 1.0) * b2 * 0.75;
      }

      // Subtle deep-space background dust / galactic glow
      float band = exp(-abs(rayDir.y * 2.6 - rayDir.x * 0.8) * 3.5);
      float dust = fbm(rayDir.xz * 3.5 + rayDir.y * 2.0);
      stars += vec3(0.05, 0.04, 0.065) * band * dust;

      return stars;
    }

    // Filmic ACES-inspired tone curve preserving pitch-black shadow
    vec3 toneMapACES(vec3 x) {
      const float a = 2.51;
      const float b = 0.03;
      const float c = 2.43;
      const float d = 0.59;
      const float e = 0.14;
      return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
    }

    void main() {
      // Normalized screen coordinates with aspect ratio
      vec2 ndc = vUv * 2.0 - 1.0;
      ndc.x *= uResolution.x / max(uResolution.y, 1.0);

      // Camera pinhole ray (FOV ~ 42 deg)
      const float fovScale = 0.42;
      vec3 rd = normalize(uCamForward + (ndc.x * fovScale) * uCamRight + (ndc.y * fovScale) * uCamUp);
      vec3 p = uCamPos;
      vec3 v = rd;

      // Conserved specific angular momentum vector L = r x v
      vec3 Lvec = cross(p, v);
      float h2 = dot(Lvec, Lvec);
      float b = sqrt(h2);

      vec3 diskColor = vec3(0.0);
      float diskAlpha = 0.0;
      bool captured = false;

      // Symplectic Schwarzschild geodesic raymarcher
      // Exact 3D orbital ODE: d^2p/dlambda^2 = -1.5 * RS * h^2 * p / r^5
      for (int step = 0; step < 96; step++) {
        vec3 prevP = p;
        vec3 prevV = v;

        float r2 = dot(p, p);
        float invR = inversesqrt(r2);
        float r = r2 * invR;

        // Strict capture inside event horizon or inward-moving inside photon sphere (r <= 1.5 r_s)
        // Guarantees zero star or disk leakage inside the black hole shadow
        if (r <= 1.02 || (r < 1.495 && dot(p, v) < 0.0)) {
          captured = true;
          break;
        }

        // Escape condition once moving away outside the accretion disk bounding sphere
        if (r > 24.5 && dot(p, v) > 0.0) {
          break;
        }

        // Geodesic acceleration
        float invR2 = invR * invR;
        float invR5 = invR2 * invR2 * invR;
        vec3 acc = (-1.5 * RS * h2 * invR5) * p;

        // Adaptive step size: fine near photon sphere & equatorial plane, larger far out
        float dt = clamp(0.048 * (r - 0.90), 0.024, 0.48);
        if (r > 2.1 && r < 15.2) {
          dt = min(dt, max(0.032, 0.36 * abs(p.y)));
        }

        v = normalize(v + acc * dt);
        p = p + v * dt;

        // Check equatorial plane crossing (y = 0)
        if (prevP.y * p.y <= 0.0) {
          float frac = abs(prevP.y) / (abs(prevP.y) + abs(p.y) + 1e-7);
          vec3 hitP = mix(prevP, p, frac);
          vec3 hitV = normalize(mix(prevV, v, frac));
          float hitR = length(hitP.xz);

          if (hitR >= R_IN && hitR <= R_OUT) {
            shadeAccretionDisk(hitP, hitV, hitR, diskColor, diskAlpha);
            if (diskAlpha > 0.985) {
              break;
            }
          }
        }
      }

      // Analytical guarantee: any ray with impact parameter b < b_crit cannot escape to infinity
      if (b < B_CRIT) {
        captured = true;
      }

      vec3 finalLinear = diskColor;

      // Crisp relativistic photon ring & lensed background stars ONLY outside the shadow (b >= B_CRIT)
      if (!captured) {
        float db = b - B_CRIT;

        // Crisp photon ring right outside the shadow edge (n >= 2 asymptotic photon sphere orbit)
        float photonRingCore = exp(-db * 46.0) * smoothstep(0.0, 0.005, db);
        float photonRingGlow = exp(-db * 11.5) * 0.24;

        // Relativistic Doppler asymmetry along the photon ring
        vec3 ringTangent = normalize(vec3(uCamPos.z, 0.0, -uCamPos.x));
        float ringApproach = dot(rd, -ringTangent) * (B_CRIT * 0.15);
        float ringDoppler = clamp(1.0 + 0.55 * uDoppler * ringApproach, 0.42, 1.75);

        vec3 ringColor = vec3(1.0, 0.85, 0.58) * (photonRingCore * 2.35 + photonRingGlow) * ringDoppler;
        finalLinear += (1.0 - diskAlpha * 0.65) * ringColor;

        // Lensed background starfield attenuated by opaque disk regions
        if (diskAlpha < 0.99) {
          vec3 bgStars = sampleLensedStarfield(v, rd, Lvec, b);
          finalLinear += (1.0 - diskAlpha) * bgStars;
        }
      }

      // Exposure & ACES filmic tone mapping
      vec3 exposed = finalLinear * uExposure;
      vec3 mapped = toneMapACES(exposed);

      // Gamma correction (sRGB)
      vec3 srgb = pow(mapped, vec3(1.0 / 2.2));
      gl_FragColor = vec4(srgb, 1.0);
    }
  `;

  // Upscale fragment shader (draws the reduced-resolution FBO to the full-window canvas)
  const upscaleFragSource = `
    precision mediump float;
    varying vec2 vUv;
    uniform sampler2D uTex;
    void main() {
      gl_FragColor = texture2D(uTex, vUv);
    }
  `;

  function compileShader(type, source) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
      console.error("Shader compile error:", gl.getShaderInfoLog(shader));
      gl.deleteShader(shader);
      return null;
    }
    return shader;
  }

  function createProgram(vsSource, fsSource) {
    const vs = compileShader(gl.VERTEX_SHADER, vsSource);
    const fs = compileShader(gl.FRAGMENT_SHADER, fsSource);
    if (!vs || !fs) return null;
    const prog = gl.createProgram();
    gl.attachShader(prog, vs);
    gl.attachShader(prog, fs);
    gl.linkProgram(prog);
    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
      console.error("Program link error:", gl.getProgramInfoLog(prog));
      return null;
    }
    return prog;
  }

  let bhProgram = null;
  let upscaleProgram = null;
  let quadBuffer = null;
  let fbo = null;
  let fboTexture = null;

  // Cached uniform/attribute locations
  let bhLocs = null;
  let upLocs = null;

  if (gl) {
    bhProgram = createProgram(vertexShaderSource, blackHoleFragSource);
    upscaleProgram = createProgram(vertexShaderSource, upscaleFragSource);

    // Single fullscreen triangle covering [-1, 1]^2
    quadBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]),
      gl.STATIC_DRAW
    );

    bhLocs = {
      aPos: gl.getAttribLocation(bhProgram, "aPos"),
      uResolution: gl.getUniformLocation(bhProgram, "uResolution"),
      uTime: gl.getUniformLocation(bhProgram, "uTime"),
      uCamPos: gl.getUniformLocation(bhProgram, "uCamPos"),
      uCamForward: gl.getUniformLocation(bhProgram, "uCamForward"),
      uCamRight: gl.getUniformLocation(bhProgram, "uCamRight"),
      uCamUp: gl.getUniformLocation(bhProgram, "uCamUp"),
      uExposure: gl.getUniformLocation(bhProgram, "uExposure"),
      uDoppler: gl.getUniformLocation(bhProgram, "uDoppler")
    };

    upLocs = {
      aPos: gl.getAttribLocation(upscaleProgram, "aPos"),
      uTex: gl.getUniformLocation(upscaleProgram, "uTex")
    };

    fbo = gl.createFramebuffer();
    fboTexture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, fboTexture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  }

  function resizeBuffers() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    fullWidth = Math.max(1, Math.floor(window.innerWidth * dpr));
    fullHeight = Math.max(1, Math.floor(window.innerHeight * dpr));

    canvas.width = fullWidth;
    canvas.height = fullHeight;

    internalWidth = Math.max(64, Math.floor(fullWidth * resScale));
    internalHeight = Math.max(64, Math.floor(fullHeight * resScale));

    resReadout.textContent = internalWidth + "×" + internalHeight;

    if (gl && fbo && fboTexture) {
      gl.bindTexture(gl.TEXTURE_2D, fboTexture);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        internalWidth,
        internalHeight,
        0,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        null
      );
      gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
      gl.framebufferTexture2D(
        gl.FRAMEBUFFER,
        gl.COLOR_ATTACHMENT0,
        gl.TEXTURE_2D,
        fboTexture,
        0
      );
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    }
  }

  window.addEventListener("resize", resizeBuffers);
  resizeBuffers();

  // Pre-allocated camera basis vectors (zero allocations per frame)
  const camPos = new Float32Array(3);
  const camForward = new Float32Array(3);
  const camRight = new Float32Array(3);
  const camUp = new Float32Array(3);
  const baseRight = new Float32Array(3);
  const baseUp = new Float32Array(3);

  function updateCameraBasis() {
    // Smooth damping for preset transitions and wheel zoom
    camElevationDeg += (targetElevationDeg - camElevationDeg) * 0.22;
    camAzimuth += (targetAzimuth - camAzimuth) * 0.22;
    camDistance += (targetDistance - camDistance) * 0.18;

    const elevRad = camElevationDeg * (Math.PI / 180.0);
    const cosElev = Math.cos(elevRad);
    const sinElev = Math.sin(elevRad);
    const cosAzim = Math.cos(camAzimuth);
    const sinAzim = Math.sin(camAzimuth);

    camPos[0] = camDistance * cosElev * sinAzim;
    camPos[1] = camDistance * sinElev;
    camPos[2] = camDistance * cosElev * cosAzim;

    // Forward = normalize(-camPos)
    const invDist = 1.0 / camDistance;
    camForward[0] = -camPos[0] * invDist;
    camForward[1] = -camPos[1] * invDist;
    camForward[2] = -camPos[2] * invDist;

    // Gimbal-lock-free horizontal right vector orthogonal to forward for all [-90°, +90°]
    baseRight[0] = cosAzim;
    baseRight[1] = 0.0;
    baseRight[2] = -sinAzim;

    // baseUp = cross(baseRight, camForward)
    baseUp[0] = baseRight[1] * camForward[2] - baseRight[2] * camForward[1];
    baseUp[1] = baseRight[2] * camForward[0] - baseRight[0] * camForward[2];
    baseUp[2] = baseRight[0] * camForward[1] - baseRight[1] * camForward[0];

    // Subtle cinematic roll near edge-on that smoothly vanishes as camera approaches face-on
    const rollAngle = -0.09 * cosElev;
    const cR = Math.cos(rollAngle);
    const sR = Math.sin(rollAngle);

    camRight[0] = cR * baseRight[0] + sR * baseUp[0];
    camRight[1] = cR * baseRight[1] + sR * baseUp[1];
    camRight[2] = cR * baseRight[2] + sR * baseUp[2];

    camUp[0] = -sR * baseRight[0] + cR * baseUp[0];
    camUp[1] = -sR * baseRight[1] + cR * baseUp[1];
    camUp[2] = -sR * baseRight[2] + cR * baseUp[2];
  }

  // Frame timing
  let frameCount = 0;
  let lastFpsUpdate = performance.now();

  function renderFrame(now) {
    requestAnimationFrame(renderFrame);

    frameCount++;
    if (now - lastFpsUpdate >= 450) {
      const fps = Math.round((frameCount * 1000) / (now - lastFpsUpdate));
      fpsReadout.textContent = fps;
      frameCount = 0;
      lastFpsUpdate = now;
    }

    updateCameraBasis();

    if (!gl || !bhProgram || !upscaleProgram) return;

    const timeSec = now * 0.001;

    // PASS 1: Raymarch Schwarzschild Black Hole at reduced internal resolution into FBO
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.viewport(0, 0, internalWidth, internalHeight);
    gl.useProgram(bhProgram);

    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
    gl.enableVertexAttribArray(bhLocs.aPos);
    gl.vertexAttribPointer(bhLocs.aPos, 2, gl.FLOAT, false, 0, 0);

    gl.uniform2f(bhLocs.uResolution, internalWidth, internalHeight);
    gl.uniform1f(bhLocs.uTime, timeSec);
    gl.uniform3fv(bhLocs.uCamPos, camPos);
    gl.uniform3fv(bhLocs.uCamForward, camForward);
    gl.uniform3fv(bhLocs.uCamRight, camRight);
    gl.uniform3fv(bhLocs.uCamUp, camUp);
    gl.uniform1f(bhLocs.uExposure, exposure);
    gl.uniform1f(bhLocs.uDoppler, dopplerStrength);

    gl.drawArrays(gl.TRIANGLES, 0, 3);

    // PASS 2: Upscale reduced-resolution FBO texture to full-window canvas
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, fullWidth, fullHeight);
    gl.useProgram(upscaleProgram);

    gl.bindTexture(gl.TEXTURE_2D, fboTexture);
    gl.uniform1i(upLocs.uTex, 0);

    gl.drawArrays(gl.TRIANGLES, 0, 3);
  }

  requestAnimationFrame(renderFrame);
})();
</script>
</body>
</html>

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.