Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created July 15, 2025 18:13
Show Gist options
  • Select an option

  • Save shricodev/3f47123218370499f798ffb379940186 to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/3f47123218370499f798ffb379940186 to your computer and use it in GitHub Desktop.
Black Hole Animation - Grok 4 - Blog Demo
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Black Hole Visualization</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
overflow: hidden;
background: radial-gradient(ellipse at center, #0a001a 0%, #000000 70%);
color: #d0c0ff;
font-family: 'Orbitron', sans-serif;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#info {
position: absolute;
top: 30px;
width: 100%;
text-align: center;
color: rgba(200, 180, 255, 0.85);
font-size: 20px;
letter-spacing: 1px;
pointer-events: none;
z-index: 100;
text-shadow: 0 0 10px rgba(100, 50, 255, 0.5);
transition: opacity 3s ease-in-out 2s;
animation: pulse 4s infinite ease-in-out;
}
@keyframes pulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.6; } }
.ui-panel {
position: absolute;
background-image: linear-gradient(145deg, rgba(25, 10, 45, 0.9), rgba(15, 5, 30, 0.95));
backdrop-filter: blur(12px) saturate(170%);
-webkit-backdrop-filter: blur(12px) saturate(170%);
padding: 12px 18px;
border-radius: 12px;
border: 1px solid rgba(200, 180, 255, 0.2);
color: rgba(240, 230, 255, 0.95);
font-size: 15px;
user-select: none;
z-index: 50;
transition: all 0.4s ease;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(200,180,255,0.1) inset;
box-sizing: border-box;
opacity: 0;
transform: translateX(-15px);
animation: panelSlideIn 0.8s cubic-bezier(0.22, 0.61, 0.36, 1) 0.3s forwards;
}
@keyframes panelSlideIn { to { opacity: 1; transform: translateX(0); } }
.ui-panel:hover {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(200,180,255,0.15) inset, 0 0 15px rgba(100, 50, 255, 0.3);
}
#controls { bottom: 30px; left: 30px; }
#autoRotateToggle {
cursor: pointer; padding: 10px; display: flex; align-items: center;
gap: 10px; color: inherit; font-size: inherit; transition: all 0.3s ease;
border-radius: 6px; background: rgba(100, 50, 255, 0.1);
}
#autoRotateToggle:hover { color: #ffffff; background: rgba(100, 50, 255, 0.3); box-shadow: 0 0 10px rgba(100, 50, 255, 0.5); }
#autoRotateToggle span { vertical-align: middle; }
.rotate-icon {
width: 1.2em; height: 1.2em; stroke: currentColor; stroke-width: 2.0;
fill: none; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle;
filter: drop-shadow(0 0 2px rgba(100, 50, 255, 0.7));
}
@media (max-width: 640px) {
.ui-panel { padding: 8px 10px; border-radius: 6px; }
#controls { max-width: 140px; left: 15px; bottom: 15px; }
#info { font-size: 16px; top: 20px; }
#info span { font-size: 12px; }
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.163.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.163.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="info">
Cosmic Black Hole<br>
<span style="font-size: 14px; opacity: 0.8;">Drag to Explore the Void</span>
</div>
<div id="controls" class="ui-panel">
<div id="autoRotateToggle" title="Toggle cosmic rotation"></div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const BLACK_HOLE_RADIUS = 1.5;
const DISK_INNER_RADIUS = BLACK_HOLE_RADIUS + 0.3;
const DISK_OUTER_RADIUS = 10.0;
const DISK_TILT_ANGLE = Math.PI / 4.0;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x020104, 0.02);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 5000);
camera.position.set(-7, 6, 7);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.3;
document.body.appendChild(renderer.domElement);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.9, 0.6, 0.7
);
composer.addPass(bloomPass);
const lensingShader = {
uniforms: {
"tDiffuse": { value: null },
"blackHoleScreenPos": { value: new THREE.Vector2(0.5, 0.5) },
"lensingStrength": { value: 0.15 },
"lensingRadius": { value: 0.35 },
"aspectRatio": { value: window.innerWidth / window.innerHeight },
"chromaticAberration": { value: 0.006 }
},
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec2 blackHoleScreenPos;
uniform float lensingStrength;
uniform float lensingRadius;
uniform float aspectRatio;
uniform float chromaticAberration;
varying vec2 vUv;
void main() {
vec2 screenPos = vUv;
vec2 toCenter = screenPos - blackHoleScreenPos;
toCenter.x *= aspectRatio;
float dist = length(toCenter);
float distortionAmount = lensingStrength / (dist * dist + 0.003);
distortionAmount = clamp(distortionAmount, 0.0, 0.7);
float falloff = smoothstep(lensingRadius, lensingRadius * 0.3, dist);
distortionAmount *= falloff;
vec2 offset = normalize(toCenter) * distortionAmount;
offset.x /= aspectRatio;
vec2 distortedUvR = screenPos - offset * (1.0 + chromaticAberration);
vec2 distortedUvG = screenPos - offset;
vec2 distortedUvB = screenPos - offset * (1.0 - chromaticAberration);
float r = texture2D(tDiffuse, distortedUvR).r;
float g = texture2D(tDiffuse, distortedUvG).g;
float b = texture2D(tDiffuse, distortedUvB).b;
gl_FragColor = vec4(r, g, b, 1.0);
}`
};
const lensingPass = new ShaderPass(lensingShader);
composer.addPass(lensingPass);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = 0.04;
controls.rotateSpeed = 0.5; controls.autoRotate = false;
controls.autoRotateSpeed = 0.15;
controls.target.set(0, 0, 0);
controls.minDistance = 3;
controls.maxDistance = 120;
controls.enablePan = false;
controls.update();
let autoRotateEnabled = false;
const autoRotateToggle = document.getElementById('autoRotateToggle');
const rotateIconSVG = `<svg class="rotate-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 4V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`;
updateAutoRotateText();
autoRotateToggle.addEventListener('click', () => {
autoRotateEnabled = !autoRotateEnabled; controls.autoRotate = autoRotateEnabled; updateAutoRotateText();
});
function updateAutoRotateText() {
autoRotateToggle.innerHTML = rotateIconSVG + `<span>Spin: ${autoRotateEnabled ? "Active" : "Paused"}</span>`;
}
const starGeometry = new THREE.BufferGeometry();
const starCount = 200000;
const starPositions = new Float32Array(starCount * 3);
const starColors = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
const starTwinkle = new Float32Array(starCount);
const starFieldRadius = 2500;
const starPalette = [
new THREE.Color(0x88aaff), new THREE.Color(0xffaaff), new THREE.Color(0xaaffff),
new THREE.Color(0xffddaa), new THREE.Color(0xffeecc), new THREE.Color(0xffffff),
new THREE.Color(0xff8888), new THREE.Color(0x88ff88), new THREE.Color(0xffff88),
new THREE.Color(0x88ffff), new THREE.Color(0xff88ff), new THREE.Color(0x88ffaa)
];
for (let i = 0; i < starCount; i++) {
const i3 = i * 3;
const phi = Math.acos(-1 + (2 * i) / starCount);
const theta = Math.sqrt(starCount * Math.PI) * phi;
const radius = Math.cbrt(Math.random()) * starFieldRadius + 150;
starPositions[i3] = radius * Math.sin(phi) * Math.cos(theta);
starPositions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
starPositions[i3 + 2] = radius * Math.cos(phi);
const starColor = starPalette[Math.floor(Math.random() * starPalette.length)].clone();
starColor.multiplyScalar(Math.random() * 0.8 + 0.2);
starColors[i3] = starColor.r; starColors[i3 + 1] = starColor.g; starColors[i3 + 2] = starColor.b;
starSizes[i] = THREE.MathUtils.randFloat(0.5, 3.5);
starTwinkle[i] = Math.random() * Math.PI * 2;
}
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
starGeometry.setAttribute('color', new THREE.BufferAttribute(starColors, 3));
starGeometry.setAttribute('size', new THREE.BufferAttribute(starSizes, 1));
starGeometry.setAttribute('twinkle', new THREE.BufferAttribute(starTwinkle, 1));
const starMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uPixelRatio: { value: renderer.getPixelRatio() }
},
vertexShader: `
uniform float uTime;
uniform float uPixelRatio;
attribute float size;
attribute float twinkle;
varying vec3 vColor;
varying float vTwinkle;
void main() {
vColor = color;
vTwinkle = sin(uTime * 3.0 + twinkle) * 0.5 + 0.5;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * uPixelRatio * (350.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vTwinkle;
void main() {
float dist = distance(gl_PointCoord, vec2(0.5));
if (dist > 0.5) discard;
float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
alpha *= (0.3 + vTwinkle * 0.7);
gl_FragColor = vec4(vColor, alpha);
}
`,
transparent: true,
vertexColors: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const stars = new THREE.Points(starGeometry, starMaterial);
scene.add(stars);
const eventHorizonGeom = new THREE.SphereGeometry(BLACK_HOLE_RADIUS * 1.1, 128, 64);
const eventHorizonMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uCameraPosition: { value: camera.position }
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vNormal = normalize(normalMatrix * normal);
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uCameraPosition;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vec3 viewDirection = normalize(uCameraPosition - vPosition);
float fresnel = 1.0 - abs(dot(vNormal, viewDirection));
fresnel = pow(fresnel, 2.0);
vec3 glowColor = vec3(1.0, 0.5, 0.2);
float pulse = sin(uTime * 3.0) * 0.2 + 0.8;
gl_FragColor = vec4(glowColor * fresnel * pulse, fresnel * 0.5);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
side: THREE.BackSide
});
const eventHorizon = new THREE.Mesh(eventHorizonGeom, eventHorizonMat);
scene.add(eventHorizon);
const blackHoleGeom = new THREE.SphereGeometry(BLACK_HOLE_RADIUS, 128, 64);
const blackHoleMat = new THREE.MeshBasicMaterial({ color: 0x000000 });
const blackHoleMesh = new THREE.Mesh(blackHoleGeom, blackHoleMat);
blackHoleMesh.renderOrder = 0;
scene.add(blackHoleMesh);
const diskGeometry = new THREE.RingGeometry(DISK_INNER_RADIUS, DISK_OUTER_RADIUS, 256, 128);
const diskMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uColorHot: { value: new THREE.Color(0xffeecc) },
uColorMid1: { value: new THREE.Color(0xff7733) },
uColorMid2: { value: new THREE.Color(0xff4477) },
uColorMid3: { value: new THREE.Color(0x7744ff) },
uColorOuter: { value: new THREE.Color(0x4477ff) },
uNoiseScale: { value: 3.0 },
uFlowSpeed: { value: 0.25 },
uDensity: { value: 1.5 }
},
vertexShader: `
varying vec2 vUv;
varying float vRadius;
varying float vAngle;
void main() {
vUv = uv;
vRadius = length(position.xy);
vAngle = atan(position.y, position.x);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec3 uColorHot;
uniform vec3 uColorMid1;
uniform vec3 uColorMid2;
uniform vec3 uColorMid3;
uniform vec3 uColorOuter;
uniform float uNoiseScale;
uniform float uFlowSpeed;
uniform float uDensity;
varying vec2 vUv;
varying float vRadius;
varying float vAngle;
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy) );
vec3 x0 = v - i + dot(i, C.xxx) ;
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min( g.xyz, l.zxy );
vec3 i2 = max( g.xyz, l.zxy );
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute( permute( permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_ );
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4( x.xy, y.xy );
vec4 b1 = vec4( x.zw, y.zw );
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
vec3 p0 = vec3(a0.xy,h.x);
vec3 p1 = vec3(a0.zw,h.y);
vec3 p2 = vec3(a1.xy,h.z);
vec3 p3 = vec3(a1.zw,h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3) ) );
}
void main() {
float normalizedRadius = smoothstep(${DISK_INNER_RADIUS.toFixed(2)}, ${DISK_OUTER_RADIUS.toFixed(2)}, vRadius);
float spiral = vAngle * 4.0 - (1.0 / (normalizedRadius + 0.1)) * 2.5;
vec2 noiseUv = vec2(vUv.x + uTime * uFlowSpeed * (2.5 / (vRadius * 0.3 + 1.0)) + sin(spiral) * 0.15, vUv.y * 0.7 + cos(spiral) * 0.15);
float noiseVal1 = snoise(vec3(noiseUv * uNoiseScale, uTime * 0.2));
float noiseVal2 = snoise(vec3(noiseUv * uNoiseScale * 3.5 + 0.8, uTime * 0.25));
float noiseVal3 = snoise(vec3(noiseUv * uNoiseScale * 7.0 + 1.5, uTime * 0.35));
float noiseVal = (noiseVal1 * 0.4 + noiseVal2 * 0.4 + noiseVal3 * 0.2);
noiseVal = (noiseVal + 1.0) * 0.5;
vec3 color = uColorOuter;
color = mix(color, uColorMid3, smoothstep(0.0, 0.3, normalizedRadius));
color = mix(color, uColorMid2, smoothstep(0.25, 0.6, normalizedRadius));
color = mix(color, uColorMid1, smoothstep(0.55, 0.8, normalizedRadius));
color = mix(color, uColorHot, smoothstep(0.75, 1.0, normalizedRadius));
color *= (0.6 + noiseVal * 1.0);
float brightness = pow(1.0 - normalizedRadius, 1.2) * 4.0 + 0.6;
brightness *= (0.4 + noiseVal * 2.0);
float pulse = sin(uTime * 2.0 + normalizedRadius * 15.0 + vAngle * 2.5) * 0.2 + 0.8;
brightness *= pulse;
float alpha = uDensity * (0.25 + noiseVal * 0.85);
alpha *= smoothstep(0.0, 0.2, normalizedRadius);
alpha *= (1.0 - smoothstep(0.8, 1.0, normalizedRadius));
alpha = clamp(alpha, 0.0, 1.0);
gl_FragColor = vec4(color * brightness, alpha);
}
`,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const accretionDisk = new THREE.Mesh(diskGeometry, diskMaterial);
accretionDisk.rotation.x = DISK_TILT_ANGLE;
accretionDisk.renderOrder = 1;
scene.add(accretionDisk);
setTimeout(() => { const info = document.getElementById('info'); if (info) info.style.opacity = '0'; }, 6000);
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
bloomPass.resolution.set(window.innerWidth, window.innerHeight);
lensingPass.uniforms.aspectRatio.value = window.innerWidth / window.innerHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
}, 150);
});
const clock = new THREE.Clock();
const blackHoleScreenPosVec3 = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
const elapsedTime = clock.getElapsedTime();
const deltaTime = clock.getDelta();
diskMaterial.uniforms.uTime.value = elapsedTime;
starMaterial.uniforms.uTime.value = elapsedTime;
eventHorizonMat.uniforms.uTime.value = elapsedTime;
eventHorizonMat.uniforms.uCameraPosition.value.copy(camera.position);
blackHoleScreenPosVec3.copy(blackHoleMesh.position).project(camera);
lensingPass.uniforms.blackHoleScreenPos.value.set(
(blackHoleScreenPosVec3.x + 1) / 2,
(blackHoleScreenPosVec3.y + 1) / 2
);
controls.update();
stars.rotation.y += deltaTime * 0.004;
stars.rotation.x += deltaTime * 0.002;
accretionDisk.rotation.z += deltaTime * 0.006;
composer.render(deltaTime);
}
animate();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment