The black hole, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
88tok/s
First token included
30.2Ktokens
5m 45s
$0.15
Show the code the model wroteHide the code
<!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) • Drag horizontally: Orbit • 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.