玩玩卡冈图雅黑洞可视化
Published:
很多人都知道星际穿越里的黑洞形象, Gemini 3.0 已经可以帮助我们很方便地可视化他了🤗
感觉包装包装都能当做壁纸呢, 再加入一点随机事件什么的, 或者再加入一点互动性要素什么的, 感觉真的好好玩的.
顺带附上源码, 欢迎体验:
---
layout: null
permalink: /BH+disk/
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js Black Hole - Cinematic Version</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
canvas { display: block; width: 100vw; height: 100vh; outline: none;}
/* --- UI Container --- */
#ui-container {
position: absolute;
bottom: 40px;
left: 40px;
z-index: 10;
user-select: none;
perspective: 1000px;
}
/* --- Frosted Glass Card (UI卡片样式) --- */
.glass-card {
background: rgba(12, 18, 24, 0.45);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(100, 200, 255, 0.15);
border-top: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 14px; /* 圆角稍微减小以适配紧凑感 */
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.6),
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
/* [UPDATE] 缩减内边距以降低高度 */
padding: 16px 20px;
color: rgba(255, 255, 255, 0.95);
width: 170px; /* 稍微加宽一点点以容纳更长的文字 */
pointer-events: auto;
transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
text-align: center;
}
.glass-card:hover {
transform: translateY(-2px);
background: rgba(15, 22, 30, 0.55);
border-color: rgba(100, 200, 255, 0.4);
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.8);
}
/* --- Sci-Fi Typography --- */
h1 {
/* [UPDATE] 减少标题下边距 */
margin: 0 0 10px 0;
font-family: 'Courier New', Courier, monospace;
font-weight: 800;
/* [UPDATE] 字体稍微改小 */
font-size: 20px;
letter-spacing: 2px;
text-transform: uppercase;
background: linear-gradient(90deg, #e0f7fa 0%, #4dd0e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 15px rgba(77, 208, 225, 0.5);
border-bottom: 2px solid rgba(77, 208, 225, 0.2);
padding-bottom: 8px;
}
.controls-list {
list-style: none;
padding: 0;
margin: 0;
font-size: 12.5px; /* 字体改小以适配紧凑布局 */
font-weight: 700;
letter-spacing: 0.5px;
/* [UPDATE] 减少行高,让文字更紧凑 */
line-height: 1.8;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
}
.controls-list li {
display: flex;
align-items: center;
justify-content: flex-start; /* 文字左对齐或者居中,这里选左对齐看起来更整齐,或者居中也可以 */
justify-content: center;
color: rgba(220, 240, 255, 0.9);
width: 100%;
white-space: nowrap; /* 防止换行 */
}
.icon {
margin-right: 6px;
font-size: 12px;
color: #4dd0e1;
text-shadow: 0 0 8px rgba(77, 208, 225, 0.6);
}
/* --- Toggle Switch --- */
.toggle-container {
/* [UPDATE] 减少顶部边距 */
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
font-weight: 800;
color: rgba(180, 210, 220, 0.9);
letter-spacing: 1px;
}
.switch {
position: relative;
display: inline-block;
width: 32px; /* 稍微缩小开关 */
height: 16px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(255, 255, 255, 0.15);
transition: .3s;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.3);
}
.slider:before {
position: absolute;
content: "";
height: 10px;
width: 10px;
left: 2px;
bottom: 2px;
background-color: rgba(255,255,255,0.9);
transition: .3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: rgba(77, 208, 225, 0.4);
border-color: #4dd0e1;
}
input:checked + .slider:before {
transform: translateX(16px);
background-color: #4dd0e1;
box-shadow: 0 0 8px #4dd0e1;
}
/* Status Dot */
.status-dot {
position: absolute;
top: 18px; /* 根据新的padding调整位置 */
right: 16px;
width: 6px;
height: 6px;
background-color: #4dd0e1;
border-radius: 50%;
box-shadow: 0 0 8px #4dd0e1;
}
</style>
</head>
<body>
<div id="ui-container">
<div class="glass-card">
<div class="status-dot"></div>
<h1>GARGANTUA</h1>
<!-- [UPDATE] 更新后的文案 -->
<ul class="controls-list">
<li><span class="icon">◎</span> 左键拖动旋转视角</li>
<li><span class="icon">✥</span> 右键拖动平移位置</li>
<li><span class="icon">↔</span> 鼠标滚轮缩放镜头</li>
</ul>
<div class="toggle-container">
<span>DYNAMICAL</span>
<label class="switch">
<input type="checkbox" id="anim-toggle" checked>
<span class="slider"></span>
</label>
</div>
</div>
</div>
<!-- Import Three.js -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- 配置项 (可以在这里调整基本几何参数) ---
const config = {
blackHoleRadius: 1.0, // [视觉] 黑洞视界半径 (Schwarzschild Radius)
diskInner: 3.0, // [视觉] 吸积盘内缘半径 (理论上最小稳定轨道是3.0, 视觉上为了美观取小一点)
diskOuter: 10.0, // [视觉] 吸积盘外缘半径
gravitationalStrength: 1.3 // [物理] 引力透镜强度系数 (影响光线弯曲程度)
};
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 3, 20);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 1.5; // [交互] 最小缩放距离 (越小越能贴近黑洞)
controls.maxDistance = 60.0; // [交互] 最大缩放距离 (允许拉远看星空)
controls.enablePan = true;
controls.panSpeed = 0.5;
// --- Shader 材质定义 (核心渲染逻辑) ---
const blackHoleMaterial = new THREE.ShaderMaterial({
uniforms: {
iTime: { value: 0 },
iResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
iCameraPos: { value: camera.position },
iCameraDir: { value: new THREE.Vector3() },
iCameraUp: { value: camera.up },
iFov: { value: camera.fov },
uDiskInner: { value: config.diskInner },
uDiskOuter: { value: config.diskOuter },
uBhRadius: { value: config.blackHoleRadius }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float iTime;
uniform vec2 iResolution;
uniform vec3 iCameraPos;
uniform vec3 iCameraDir;
uniform vec3 iCameraUp;
uniform float iFov;
uniform float uDiskInner;
uniform float uDiskOuter;
uniform float uBhRadius;
varying vec2 vUv;
// [性能] 最大光线追踪步数 (越高画质越好, 但越卡)
#define MAX_STEPS 150
// [物理] 光线追踪的最大距离 (超过这个距离认为光线逃逸到背景)
#define MAX_DIST 150.0
#define PI 3.14159265359
// --- 随机噪声函数库 ---
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float hash3(vec3 p) {
p = fract(p * 0.3183099 + .1);
p *= 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)); float d = hash(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 f = 0.0; float w = 0.5; float scale = 1.0;
for (int i = 0; i < 4; i++) {
f += w * noise(p * scale); scale *= 2.0; w *= 0.5;
}
return f;
}
// --- 星空背景生成函数 ---
vec3 getStarField(vec3 dir) {
vec3 col = vec3(0.0);
// [视觉] 背景缓慢旋转速度
float theta = iTime * 0.012;
float cs = cos(theta);
float sn = sin(theta);
mat2 rot = mat2(cs, -sn, sn, cs);
dir.xz = rot * dir.xz;
dir.xy = rot * dir.xy;
// 双层星空循环 (i=1: 大星/近星, i=2: 小星/远星)
for(float i=1.0; i<=2.0; i++) {
// [视觉] 星星网格缩放 (数字越大,网格越密,星星越小)
float scale = (i == 1.0) ? 20.0 : 45.0;
vec3 p = dir * scale;
vec3 id = floor(p);
vec3 local = fract(p) - 0.5;
float seed = hash3(id + i * 123.45);
// [关键调节] 星星生成阈值 (0.0 - 1.0)
// 值越小,通过率越高,星星越多。
// 原来是 0.7/0.5, 现在降低到 0.4/0.1 以大幅增加密度
float threshold = (i == 1.0) ? 0.25 : 0.1;
if (seed > threshold) {
vec3 posOffset = vec3(
fract(seed * 12.5) - 0.5,
fract(seed * 34.1) - 0.5,
fract(seed * 56.7) - 0.5
) * 0.6;
float dist = length(local - posOffset);
// [视觉] 星星大小
float sizeBase = (i == 1.0) ? 0.05 : 0.03;
float size = sizeBase + 0.04 * fract(seed * 10.0);
// 星星形状计算 (核心+光晕)
float core = smoothstep(size, size * 0.2, dist);
float glow = exp(-dist * 8.0 / size) * 0.5;
float starShape = core + glow;
// [视觉] 呼吸闪烁速度与幅度
float pulseSpeed = 0.5 + 2.0 * fract(seed * 99.0);
float pulse = 0.6 + 0.4 * sin(iTime * pulseSpeed + seed * 6.28);
// [视觉] 恒星光谱颜色 (已增强饱和度)
vec3 tint;
float colorSeed = fract(seed * 43.2);
if (colorSeed < 0.2) tint = vec3(0.4, 0.7, 1.0); // 蓝超巨星 (更蓝)
else if (colorSeed < 0.4) tint = vec3(0.6, 0.9, 1.0); // 蓝白星
else if (colorSeed < 0.7) tint = vec3(1.0, 0.95, 0.8); // 黄矮星 (太阳类)
else if (colorSeed < 0.9) tint = vec3(1.0, 0.7, 0.4); // 橙巨星
else tint = vec3(1.0, 0.3, 0.2); // 红矮星 (更红)
// [视觉] 亮度强度
float intensity = (i == 1.0) ? 3.0 : 1.5;
col += tint * starShape * pulse * intensity;
}
}
return col;
}
// --- 吸积盘颜色计算函数 ---
vec4 getDiskColor(vec3 pos, float distToCenter) {
// 范围剔除
if (distToCenter < uDiskInner - 0.1 || distToCenter > uDiskOuter + 1.0) return vec4(0.0);
float angle = atan(pos.z, pos.x);
// [物理] 开普勒旋转速度 (越近越快)
float speed = 3.5 / (distToCenter * 0.5 + 0.1);
float rotAngle = angle + iTime * speed * 0.5;
// 生成云雾噪点
float gas = fbm(vec2(distToCenter * 1.2, rotAngle * 3.5));
// 生成环状结构
float ringPattern = sin(distToCenter * 5.0 + gas * 2.5);
ringPattern = smoothstep(-0.4, 0.94, ringPattern);
float intensity = (0.4 + 0.6 * gas) * (0.3 + 0.7 * ringPattern);
// 边缘淡出处理
float outerFade = smoothstep(uDiskOuter, uDiskOuter - 3.0, distToCenter);
float innerFade = smoothstep(uDiskInner - 0.2, uDiskInner + 0.8, distToCenter);
// [视觉] 吸积盘颜色配置 (开尔文色温模拟)
vec3 colInner = vec3(1.0, 0.95, 0.9); // 内圈: 炽热白
vec3 colMid = vec3(1.0, 0.6, 0.2); // 中圈: 金橙色
vec3 colOuter = vec3(0.8, 0.1, 0.05); // 外圈: 暗红色
float t = (distToCenter - uDiskInner) / (uDiskOuter - uDiskInner);
vec3 baseColor = mix(colInner, colMid, smoothstep(0.0, 0.3, t));
baseColor = mix(baseColor, colOuter, smoothstep(0.3, 1.0, t));
// [物理] 多普勒频移 (Relativistic Beaming)
// 左侧朝向观察者变亮变蓝,右侧远离变暗变红
vec3 velocity = normalize(vec3(-pos.z, 0.0, pos.x));
vec3 viewDir = normalize(pos - iCameraPos);
float doppler = dot(velocity, viewDir);
float beam = 1.0 + doppler * 0.6;
beam = max(0.2, beam);
vec3 shiftColor = baseColor;
if (doppler > 0.0) shiftColor += vec3(0.1, 0.1, 0.2) * doppler;
else shiftColor *= vec3(1.0, 0.9, 0.8);
float alpha = innerFade * outerFade * intensity;
return vec4(shiftColor * beam * 2.0, alpha);
}
void main() {
// UV 坐标标准化 (-1 到 1)
vec2 uv = vUv * 2.0 - 1.0;
uv.x *= iResolution.x / iResolution.y;
// 摄像机坐标系构建
vec3 camPos = iCameraPos;
vec3 camDir = normalize(iCameraDir);
vec3 camRight = normalize(cross(camDir, iCameraUp));
vec3 camUp = cross(camRight, camDir);
float fovRad = iFov * PI / 180.0;
float focalLength = 1.0 / tan(fovRad * 0.5);
vec3 rayDir = normalize(camRight * uv.x + camUp * uv.y + camDir * focalLength);
// 光线追踪初始化
vec3 currentPos = camPos;
vec3 currentDir = rayDir;
vec3 accColor = vec3(0.0); // 累积颜色 (吸积盘)
float accAlpha = 0.0; // 累积透明度
float glow = 0.0; // 视界周围的光晕
bool escaped = false; // 标记光线是否逃逸到无穷远
// --- Ray Marching 主循环 ---
for(int i = 0; i < MAX_STEPS; i++) {
float r = length(currentPos);
// 1. 撞击黑洞视界
if (r < uBhRadius) {
escaped = false;
break;
}
// 2. 逃逸到背景
if (r > MAX_DIST) {
escaped = true;
break;
}
// 3. 引力弯曲计算 (牛顿近似,兼顾性能与效果)
float step = max(0.04, r * 0.08); // 自适应步长
vec3 force = -normalize(currentPos) * (1.5 / (r * r));
currentDir += force * step;
currentDir = normalize(currentDir);
vec3 nextPos = currentPos + currentDir * step;
// 4. 吸积盘求交 (检测是否穿过 Y=0 平面)
if ((currentPos.y > 0.0 && nextPos.y < 0.0) || (currentPos.y < 0.0 && nextPos.y > 0.0)) {
float t = (0.0 - currentPos.y) / (nextPos.y - currentPos.y);
vec3 hitPos = currentPos + (nextPos - currentPos) * t;
float hitDist = length(hitPos);
vec4 diskCol = getDiskColor(hitPos, hitDist);
if (diskCol.a > 0.0) {
float stepAlpha = diskCol.a * 0.8;
accColor += diskCol.rgb * (1.0 - accAlpha) * stepAlpha;
accAlpha += stepAlpha;
}
}
// 累积光晕 (接近视界时增加)
glow += 0.01 / (pow(r, 4.0) + 0.1);
currentPos = nextPos;
// 如果吸积盘不透明度已满,提前停止
if (accAlpha > 0.98) {
escaped = true; // 实际上被盘挡住了,但为了逻辑统一,视为结束
break;
}
}
vec3 finalColor = accColor;
// --- 混合星空背景 ---
// 只有当光线没有掉进黑洞时,才渲染背景
if (escaped) {
// 使用最终弯曲的光线方向采样星空 -> 自动实现引力透镜效果
vec3 starColor = getStarField(currentDir);
finalColor += starColor * (1.0 - accAlpha);
}
// 添加光晕颜色
vec3 glowColor = mix(vec3(1.0, 0.5, 0.2), vec3(0.5, 0.8, 1.0), 1.0/(glow+1.0));
finalColor += glowColor * glow * 0.025;
// --- 后处理 (Tone Mapping & Color Grading) ---
finalColor *= 0.8; // 曝光调整
// ACES Filmic Tone Mapping 拟合曲线
float a = 2.51; float b = 0.03; float c = 2.43; float d = 0.59; float e = 0.14;
finalColor = clamp((finalColor * (a * finalColor + b)) / (finalColor * (c * finalColor + d) + e), 0.0, 1.0);
finalColor = pow(finalColor, vec3(1.0 / 2.2)); // Gamma 校正
// 晕影 (Vignette) - 四角压暗
float distFromCenter = length(uv);
finalColor *= smoothstep(2.2, 0.6, distFromCenter);
gl_FragColor = vec4(finalColor, 1.0);
}
`
});
const geometry = new THREE.PlaneGeometry(2, 2);
const mesh = new THREE.Mesh(geometry, blackHoleMaterial);
mesh.frustumCulled = false;
scene.add(mesh);
const clock = new THREE.Clock();
let isAnimating = true;
let shaderTime = 0;
const toggleSwitch = document.getElementById('anim-toggle');
toggleSwitch.addEventListener('change', (e) => {
isAnimating = e.target.checked;
});
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (isAnimating) {
shaderTime += delta;
}
blackHoleMaterial.uniforms.iTime.value = shaderTime;
blackHoleMaterial.uniforms.iResolution.value.set(renderer.domElement.width, renderer.domElement.height);
blackHoleMaterial.uniforms.iCameraPos.value.copy(camera.position);
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
blackHoleMaterial.uniforms.iCameraDir.value.copy(camDir);
blackHoleMaterial.uniforms.iCameraUp.value.copy(camera.up);
controls.update();
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
blackHoleMaterial.uniforms.iFov.value = camera.fov;
});
animate();
</script>
</body>
</html>
