玩玩卡冈图雅黑洞可视化

12 minute read

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>