WebGL黑洞着色器:广义相对论真实吸积盘效果
基于WebGL(Three.js)技术实现的广义相对论着色器引擎。
Github开源
该引擎采用了一种新型的合成方式来实现真正近似黑洞吸积盘的效果。该引擎能够很好的体现在吸积盘高速旋转时产生的红移和蓝移效应。
这是一种极低成本得到最近似真实黑洞影响的一种实现方式。代码的实现和改进一部分采用了Gimini3pro进行优化。


图1是红移着色器开到最高的效果图 图二是没有开红移着色器的效果。
现在的黑洞吸积盘是顺时针旋转的,能很明显的对比出红移的效果。另外还有一个较为极端的红移展示(这样子的吸积盘表现是因为设定时是一个漏斗形状的初始状态)

效果展示的差不多了,我们来聊聊具体实现。
架构概览:CPU-GPU 混合计算
为了在保持物理交互性的同时实现数十万粒子的相对论视觉特效,我们采用了一种分层架构:
| 层级 | 负责内容 | 数据类型 |
|---|---|---|
| CPU(JavaScript) | N-body轨道积分、碰撞检测、吸积逻辑、粒子生命周期 | Float32Array |
| GPU(Vertex Shader) | 引力红移、多普勒效应、相对论集束、引力透镜、颜色混合 | attribute vec3 |
| GPU(Fragment Shader) | 纹理、光晕、像素合成 | gl_FragColor |
物理引擎实现(粒子系统)
这里实现了基础的多体运动N-body,洛希极限解体(这里为了简便直接简化为继承原有运动状态的粒子团了)。这里采用了半隐式欧拉积分。 他算的足够快,对于复杂的实时模拟上来看,它在稳定性和性能之间取得了平衡。为了实现红移,我把粒子当成光子,计算他相对于摄像机的速度,远离就是更“红”更暗,靠近就更“蓝”更亮(也实现了多普勒效应)
// 伪代码:核心物理循环for(let i =0; i < particleCount; i++){// 1. 计算合力 (牛顿引力) vec3 acc =calculateGravity(position[i], attractors);// 2. 更新速度 velocity[i]+= acc * dt;// 3. 更新位置 position[i]+= velocity[i]* dt;// 4. 史瓦西半径检查 (视界吸收)if(distance(position[i], blackhole)< SchwarzschildRadius *1.05){respawnParticle(i);// 粒子落入黑洞,重置}}虚实粒子渲染:
这里涉及到一种独特的渲染方法。为了模拟吸积盘等的绚丽场景,我们总不能真的渲染几百万个粒子吧,那样我们的电脑可能就会爆炸了。
我们可以通过一个真实的粒子运动来推断他四周的粒子云的运动方式应该是类似的。这样就可以大大减少计算的数量达到更多的真实的粒子效果。
系统架构:
classParticleManager{// 三个核心缓冲区 positions =newFloat32Array(maxCount *3);// 位置 (虚拟) velocities =newFloat32Array(maxCount *3);// 速度 (虚拟) colors =newFloat32Array(maxCount *3);// 颜色 (虚拟)// 虚实连接点:从实体位置初始化虚拟粒子emit(pos, vel, count, radius, spread, colorHex){// 基于实体位置生成虚拟粒子云this.positions[idx]= pos.x + r * Math.sin(phi)* Math.cos(theta);// ... 速度继承 + 随机扩散}}着色器中的虚实融合
// 顶点着色器关键逻辑voidmain(){ vec3 physPos = position;// 虚拟粒子位置 vec3 renderPos = physPos;// 渲染位置(可能被透镜扭曲)// 但计算红移时,考虑实体黑洞的位置for(int i =0; i < uBHCount; i++){ vec3 bhPos = uBHPositions[i];// 实体黑洞位置 float r =distance(physPos, bhPos);// 虚粒子到实体的距离// 实体影响虚粒子的渲染效果 totalGravPotential += uRs[i]/max(r, uRs[i]*1.01);}// 虚粒子颜色受实体引力影响而改变if(totalShift >1.1){ finalColor =mix(baseColor,vec3(0.4,0.6,1.0), t);}}特殊效果:虚粒子的"实体化"模拟
//吸积盘的内落效果// 在粒子更新循环中if(CONFIG.enableAccretion && dist < b.radius *1.2){ b.absorbMass(CONFIG.PARTICLE_MASS);// 虚拟粒子被实体吸收this.lifetimes[i]=0;// 虚拟粒子消失this.positions[idx]=999999;// 移出视野//虽然这样会堆积一堆发光的垃圾但是我懒得改了。还能产生一些星云效果嘻嘻。}//粒子坠入黑洞的视觉效果// 在着色器中视界消隐for(int i =0; i <2; i++){ float r =distance(physPos, uBHPositions[i]); float rs = uRs[i];// 虚拟粒子在视界内透明度为0(模拟被吞噬) vAlpha *=smoothstep(rs *0.95, rs *1.1, r);}广义相对论渲染
我采用了一种类似于星际穿越剧组渲染黑洞的方法。通过逆向解析光线弯曲来实现引力透镜和爱因斯坦环的近似效果。
我简化了一些物理原理。
简化假设:
平面近似:假设所有光线在同一平面内传播
弱场近似:使用线性叠加而非精确场方程
静态场:忽略黑洞运动带来的效应
单次散射:忽略光线绕行多圈的情况
首先是收集黑洞的信息:
// 从所有天体中找到黑洞const bhs = bodies.filter(b=> b.isBlackhole);// 按质量排序(最重要的先处理) bhs.sort((a,b)=> b.mass - a.mass);// 限制数量(性能优化)const maxLensCount =4;// 最多支持4个黑洞同时透镜 lensPass.uniforms.uCount.value = Math.min(bhs.length, maxLensCount);坐标变换(世界坐标到屏幕坐标)因为计算效果是通过摄像机的视角进行计算的。
for(let i =0; i < maxLensCount; i++){if(i < bhs.length){const bh = bhs[i];// 关键步骤:3D世界坐标 → 2D屏幕坐标// 1. 使用Three.js的project()方法const p = bh.position.clone().project(camera);// 此时p.x, p.y范围是[-1, 1],z是深度// 2. 归一化到[0,1]范围 lensPass.uniforms.uPositions.value[i].set( p.x *0.5+0.5,// 映射到[0,1] p.y *0.5+0.5, p.z );// 3. 计算屏幕空间史瓦西半径// 物理半径 → 视角 → 屏幕像素const dist = camera.position.distanceTo(bh.position);const physicalRs = bh.calcSchwarzschildRadius();// 2GM/c²const angularRs = physicalRs / dist;// 弧度const screenRs = angularRs *2.0;// 经验缩放因子 lensPass.uniforms.uRadii.value[i]= screenRs;}else{// 填充无效数据 lensPass.uniforms.uRadii.value[i]=0;}}着色器:
// lensing-fs uniform sampler2D tDiffuse;// 输入纹理(已渲染的场景) uniform vec2 uResolution;// 屏幕分辨率 uniform int uCount;// 黑洞数量(0-4) uniform vec3 uPositions[4];// 黑洞屏幕位置(x,y,z) uniform float uRadii[4];// 屏幕空间史瓦西半径 uniform float uStrength;// 透镜强度参数 varying vec2 vUv;voidmain(){// 初始化为原始UV坐标 vec2 uv = vUv; vec2 finalUv = uv; float totalMask =0.0;// 视界遮罩 float photonRing =0.0;// 光子环累积// 考虑屏幕宽高比(保证圆形扭曲) vec2 aspect =vec2(uResolution.x / uResolution.y,1.0);// 遍历所有黑洞for(int i =0; i <4; i++){if(i >= uCount)break;// 跳过无效黑洞// 获取当前黑洞参数 vec2 center = uPositions[i].xy;// 屏幕位置(x,y) float rs = uRadii[i];// 屏幕空间史瓦西半径// 计算当前像素到黑洞的距离(考虑宽高比) vec2 dir =(uv - center)* aspect; float dist =length(dir);// 视界处理if(dist < rs){// 像素在视界内,标记为完全屏蔽 totalMask =1.0;}// 扭曲计算if(dist > rs){// 关键公式:偏转角度 ∝ 1/(距离 - rs) float deflection =(rs * uStrength *0.12)/max(dist - rs,0.001);// 限制最大偏转(防止过度扭曲) deflection =min(deflection,0.5);// 计算扭曲方向:从黑洞指向像素 vec2 offset =normalize(uv - center)* deflection;// 逆向采样:应用扭曲 finalUv -= offset;// 光子环效果// 在1.1倍史瓦西半径附近添加发光效果 float glow =exp(-(dist - rs *1.1)*30.0/(rs +0.001)); photonRing += glow *0.8;}}// 最终颜色合成// 1. 采样扭曲后的纹理 vec4 color =texture2D(tDiffuse, finalUv);// 2. 添加光子环颜色(橙黄色) vec4 ringColor =vec4(1.0,0.7,0.3,1.0)* photonRing;// 3. 应用视界遮罩:视界内为纯黑色 gl_FragColor =mix(color + ringColor,vec4(0.0,0.0,0.0,1.0), totalMask);}最关键的就是逆向扭曲算法:
Q = P − ∑ i δ i ( P ) \mathbf{Q} = \mathbf{P} - \sum_i \mathbf{\delta}_i(\mathbf{P}) Q=P−i∑δi(P)
其中 δ i ( P ) \delta_i(\mathbf{P}) δi(P) 是黑洞 i i i 在像素 P \mathbf{P} P处产生的偏转。
偏转函数:
δ ( r ) = k ⋅ r s r − r s , r > r s \delta(r) = k \cdot \frac{r_s}{r - r_s}, \quad r > r_s δ(r)=k⋅r−rsrs,r>rs
其中:
r r r:像素到黑洞中心距离
r s r_s rs:屏幕空间的施瓦西半径
k k k:可调节系数
多重黑洞处理
δ total ( P ) = ∑ i = 1 N δ i ( P ) \mathbf{\delta}_{\text{total}}(\mathbf{P}) = \sum_{i=1}^{N} \mathbf{\delta}_i(\mathbf{P}) δtotal(P)=i=1∑Nδi(P)
采用了线性叠加的方案,这里是和真实的大相径庭的,当黑洞相互靠近时,非线性效应显著,会产生复杂的焦散线和多重镜像。但是暂时没办法只能妥协。
最后较为复杂的实现点就这些,也是一些必要的简化部分,ui界面和一些额外的功能采用了Gimini3pro和GLM4.7模型,帮我节省了很多的开发时间,系统还有一些小Bug和缺失的功能,但是无伤大雅后面会慢慢补上。(有点事先写到这,更多的技术细节马上补充)