Svart hål

This commit is contained in:
2026-11-01 12:09:43 +01:00
parent c4a5a4b4ed
commit 8274a20001
14 changed files with 399 additions and 7 deletions

View File

@@ -13,6 +13,29 @@ namespace Universe.App.Graphics;
/// <summary>Offscreen tests of the production shaders; no desktop capture or mouse control.</summary>
internal static class GraphicsReport
{
public static void RunBlackHole(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, StarData hole,
double cellSize, int width, int height)
{
var center = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize);
var body = center.Add(hole.PositionMeters, cellSize);
var camera = new FlyCamera(body.Add(new DVector3(0, 0, hole.RadiusMeters * 80), cellSize), cellSize);
camera.RequestBlackHoleView(neighborhood.ActiveSystem, hole, [neighborhood.ActiveSystem]);
for (var i = 0; i < 600; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
for (var i = 0; i < 4; i++) renderer.Render(camera, neighborhood, 1d / 60);
renderer.ProfileFrame = true;
renderer.Render(camera, neighborhood, 1d / 60);
renderer.ProfileFrame = false;
gl.Finish();
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Black hole GL error: {error}");
var pixels = Read(gl, width, height);
if (pixels.Any(p => !float.IsFinite(p)) || pixels.Where((_, i) => i % 4 != 3).Max() < .2)
throw new InvalidOperationException("Black hole produced invalid or empty output.");
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "black-hole.png");
WritePng(path, width, height, pixels, false);
Console.WriteLine($"Black hole: {hole.Name}; {hole.BlackHole!.MassSolar:F0} solar masses; camera {camera.Position.RelativeTo(body, cellSize).Length / hole.RadiusMeters:F2} horizons; lensing {renderer.FrameTimings["black hole lensing"]:F2} ms at {width}x{height}; image {path}");
}
public static void RunTerrainPerformance(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{
var system = neighborhood.ActiveSystem;

View File

@@ -36,6 +36,7 @@ internal sealed class PerformanceOverlay : IDisposable
AddMetric("ATMOSPHERE", "ATMOSPHERE");
AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE");
AddMetric("SUN GLOW", "SUN GLOW");
AddMetric("BLACK HOLE / LENSING", "BLACK HOLE / LENSING");
AddMetric("BLOOM / POST", "BLOOM / POST");
rows.Add(("SHADOWS IN PLANET", "SEE PLANET MS", false));
rows.Add(("CPU SUBMIT", $"{Number(cpuMilliseconds)} MS", false));

View File

@@ -0,0 +1,118 @@
#version 330 core
#include "camera-ray.glsl"
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform vec3 uCamera; // Relative to the hole, in Schwarzschild radii.
uniform vec3 uDiskX;
uniform vec3 uDiskNormal;
uniform vec3 uDiskZ;
uniform float uHorizonMeters;
uniform float uOuterRadius;
uniform float uBrightness;
uniform float uTime;
uniform float uSeed;
uniform float uFar;
uniform int uSteps;
out vec4 fragColor;
float hash21(vec2 p) {
vec3 p3=fract(vec3(p.xyx)*.1031);
p3+=dot(p3,p3.yzx+33.33);
return fract((p3.x+p3.y)*p3.z);
}
float valueNoise(vec2 p) {
vec2 i=floor(p),f=fract(p); f=f*f*(3.0-2.0*f);
float y=mod(i.y,64.0),nextY=mod(i.y+1.0,64.0);
return mix(mix(hash21(vec2(i.x,y)),hash21(vec2(i.x+1.0,y)),f.x),
mix(hash21(vec2(i.x,nextY)),hash21(vec2(i.x+1.0,nextY)),f.x),f.y);
}
vec3 diskLight(vec3 p,vec3 ray) {
float radius=length(p),angle=atan(dot(p,uDiskZ),dot(p,uDiskX));
float rotation=uTime*.24/pow(radius/3.0,1.5);
float azimuth=angle*10.185916; // 64 cells around the disk: no atan seam.
vec2 flow=vec2(log(radius)*38.0,azimuth-rotation+log(radius)*11.0);
float turbulence=valueNoise(flow+uSeed)*.55+valueNoise(flow*vec2(2.03,2.0))*.28+valueNoise(flow*vec2(4.1,4.0))*.17;
float threads=valueNoise(vec2(radius*110.0+turbulence*3.0,azimuth*2.0-rotation));
float bands=valueNoise(vec2(log(radius)*92.0,azimuth-rotation*.3));
float structure=(.35+.95*turbulence)*(.55+.65*threads)*(.6+.5*bands);
float inner=smoothstep(3.0,3.5,radius);
float outer=1.0-smoothstep(uOuterRadius*.68,uOuterRadius,radius);
float thermal=pow(3.0/max(radius,3.0),1.7);
vec3 cool=vec3(.42,.16,.07),hot=vec3(1.0,.87,.68);
vec3 tint=mix(cool,hot,pow(clamp(thermal*1.4,0.0,1.0),.6));
// Local orbital velocity and Schwarzschild redshift. This is an artistic
// thin-disk emission model, rather than a full relativistic plasma simulation.
vec3 tangent=normalize(cross(uDiskNormal,p));
float beta=sqrt(.5/max(radius-1.0,1.0));
float doppler=sqrt(1.0-beta*beta)/max(.25,1.0-beta*dot(tangent,-normalize(ray)));
float redshift=sqrt(max(.05,1.0-1.0/radius));
float boost=clamp(pow(doppler*redshift,3.0),.18,3.5);
return tint*structure*inner*outer*thermal*boost*uBrightness;
}
vec3 sky(vec3 direction) {
float front=dot(direction,uForward);
if(front>.001) {
vec2 uv=vec2(dot(direction,uRight)/(front*uAspect),dot(direction,uUp)/front)/uTanHalfFov*.5+.5;
if(all(greaterThanEqual(uv,vec2(0)))&&all(lessThanEqual(uv,vec2(1))))return texture(uScene,uv).rgb;
}
// Rays can curl behind the camera. Fill those directions with a fixed
// world-oriented starfield, without mirroring the screen at its edges.
vec2 sphere=vec2(atan(direction.z,direction.x),asin(clamp(direction.y,-1.0,1.0)))*vec2(700,700);
vec2 cell=floor(sphere),q=fract(sphere)-.5;
float star=step(.996,hash21(cell))*exp(-dot(q,q)*150.0);
float band=pow(max(0.0,1.0-abs(dot(direction,normalize(vec3(.32,.72,.48))))),17.0);
return vec3(.00018,.00025,.0005)+vec3(.0013,.0016,.0026)*band+star*vec3(.35,.42,.55);
}
vec3 acceleration(vec3 p,float angularMomentumSquared) {
float r2=dot(p,p);
// The null-geodesic orbit equation u''+u=1.5*u² (rs=1), expressed
// as a central force with conserved angular momentum.
return -1.5*angularMomentumSquared*p/(r2*r2*sqrt(r2));
}
void main() {
vec4 original=texture(uScene,vUv);
vec3 rd=cameraRay(vUv),p=uCamera;
float domain=max(32.0,uOuterRadius*2.3);
float b=dot(p,rd),c=dot(p,p)-domain*domain,discriminant=b*b-c;
if(discriminant<0.0||-b+sqrt(max(0.0,discriminant))<0.0){fragColor=original;return;}
float entry=max(0.0,-b-sqrt(discriminant));
float sceneDepth=texture(uDepth,vUv).r;
float sceneDistance=(exp2(sceneDepth*log2(1.0+uFar))-1.0)/max(.001,dot(rd,uForward))/uHorizonMeters;
if(sceneDepth<.999999&&sceneDistance<entry){fragColor=original;return;}
p+=rd*entry;
vec3 velocity=rd;
vec3 momentum=cross(p,velocity);
float h2=dot(momentum,momentum);
vec3 emission=vec3(0);
float transmission=1.0;
bool captured=false,escaped=false;
for(int i=0;i<512;i++) {
if(i>=uSteps)break;
float r=length(p);
if(r<1.025){captured=true;break;}
if(r>domain+1.0&&dot(p,velocity)>0.0){escaped=true;break;}
float stepSize=clamp(r*.085,.025,2.5);
vec3 a=acceleration(p,h2);
vec3 middle=p+velocity*stepSize*.5;
vec3 next=p+(velocity+a*stepSize*.5)*stepSize;
vec3 nextVelocity=velocity+acceleration(middle,h2)*stepSize;
float height=dot(p,uDiskNormal),nextHeight=dot(next,uDiskNormal);
if(height*nextHeight<0.0) {
vec3 hit=mix(p,next,height/(height-nextHeight));
float diskRadius=length(hit);
if(diskRadius>3.0&&diskRadius<uOuterRadius) {
float opacity=.88*(1.0-smoothstep(uOuterRadius*.8,uOuterRadius,diskRadius));
emission+=transmission*diskLight(hit,velocity)*opacity;
transmission*=1.0-opacity;
}
}
p=next;velocity=nextVelocity;
if(transmission<.008)break;
}
vec3 background=(captured||(!escaped&&transmission>.008))?vec3(0):sky(normalize(velocity));
float impact=length(cross(uCamera,rd));
float photonRing=exp(-pow((impact-2.59808)/.022,2.0))*.15;
vec3 color=emission+background*transmission+vec3(1.0,.66,.34)*photonRing;
fragColor=vec4(color,original.a);
}

View File

@@ -40,6 +40,7 @@ internal sealed class UniverseRenderer : IDisposable
private ShaderProgram _rock;
private ShaderProgram _rockPoints;
private ShaderProgram _sunGlow;
private ShaderProgram _blackHole;
private ShaderProgram _ocean;
private ShaderProgram _clouds;
private ShaderProgram _cloudComposite;
@@ -53,6 +54,7 @@ internal sealed class UniverseRenderer : IDisposable
private RenderTarget _scene = null!;
private RenderTarget _gasInput = null!;
private RenderTarget _glareTarget = null!;
private RenderTarget _blackHoleTarget = null!;
private RenderTarget _scatterA = null!;
private RenderTarget _scatterB = null!;
private RenderTarget _bloomA = null!;
@@ -107,6 +109,7 @@ internal sealed class UniverseRenderer : IDisposable
_rock = Program("rock.vert", "rock.frag");
_rockPoints = Program("rock-point.vert", "rock-point.frag");
_sunGlow = Program("fullscreen.vert", "sun-glow.frag");
_blackHole = Program("fullscreen.vert", "black-hole.frag");
_ocean = Program("fullscreen.vert", "ocean.frag");
_clouds = Program("fullscreen.vert", "cloud.frag");
_cloudComposite = Program("fullscreen.vert", "cloud-composite.frag");
@@ -168,7 +171,7 @@ internal sealed class UniverseRenderer : IDisposable
foreach (var pair in new[] { ("terrain.vert","terrain.frag"),("star.vert","star.frag"),("fullscreen.vert","sky.frag"),("point.vert","point.frag"),
("fullscreen.vert","atmosphere.frag"),("fullscreen.vert","bright.frag"),("fullscreen.vert","blur.frag"),("fullscreen.vert","final.frag"),("fullscreen.vert","ring.frag"),
("fullscreen.vert","gas.frag"),("rock.vert","rock.frag"),("rock-point.vert","rock-point.frag"),("fullscreen.vert","sun-glow.frag"),("fullscreen.vert","ocean.frag"),
("fullscreen.vert","cloud.frag"),("fullscreen.vert","cloud-composite.frag") })
("fullscreen.vert","cloud.frag"),("fullscreen.vert","cloud-composite.frag"),("fullscreen.vert","black-hole.frag") })
replacements.Add(Program(pair.Item1, pair.Item2));
}
catch (Exception error)
@@ -186,6 +189,7 @@ internal sealed class UniverseRenderer : IDisposable
_sunGlow.Dispose(); _sunGlow = replacements[12];
_ocean.Dispose(); _ocean = replacements[13];
_clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15];
_blackHole.Dispose(); _blackHole = replacements[16];
}
public void SetActiveSystem(SectorCoordinate coordinate)
@@ -211,9 +215,11 @@ internal sealed class UniverseRenderer : IDisposable
_height = System.Math.Max(height, 1);
_cloudRaw?.Dispose(); _cloudRaw = null;
_scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.Dispose();
_blackHoleTarget?.Dispose();
_scene = new(_gl, _width, _height, true);
_gasInput = new(_gl, _width, _height, true);
_glareTarget = new(_gl, _width, _height);
_blackHoleTarget = new(_gl, _width, _height);
_scatterA = new(_gl, _width, _height);
_scatterB = new(_gl, _width, _height);
_bloomA = new(_gl, System.Math.Max(1, _width / 4), System.Math.Max(1, _height / 4));
@@ -258,7 +264,7 @@ internal sealed class UniverseRenderer : IDisposable
_sky.Set("uNoise", 4); _sky.Set("uTime", (float)_time);
_sky.Set("uLightCount", system.Stars.Count);
_sky.SetVectorArray("uStarDirections", starPositions.Select(p => p.Normalized().ToVector3()).ToArray());
_sky.SetVectorArray("uStarColors", system.Stars.Select(s => s.Color).ToArray());
_sky.SetVectorArray("uStarColors", system.Stars.Select(s => s.IsBlackHole ? Vector3.Zero : s.Color).ToArray());
_sky.SetFloatArray("uStarAngles", system.Stars.Select((s, i) => (float)System.Math.Asin(System.Math.Clamp(ApparentStarRadius(s, starPositions[i].Length) / starPositions[i].Length, 0, 1))).ToArray());
_gpuProfiler.BeginPass("SKY / STARFIELD");
_fullscreen.Draw();
@@ -295,6 +301,10 @@ internal sealed class UniverseRenderer : IDisposable
composed = DrawClouds(camera, system, far, composed);
_gpuProfiler.EndPass();
Mark("clouds");
_gpuProfiler.BeginPass("BLACK HOLE / LENSING");
composed = DrawBlackHoles(camera, system, far, composed);
_gpuProfiler.EndPass();
Mark("black hole lensing");
_gpuProfiler.BeginPass("SUN GLOW");
DrawSunGlow(camera, system, starPositions, far, composed);
_gpuProfiler.EndPass();
@@ -380,6 +390,7 @@ internal sealed class UniverseRenderer : IDisposable
var origin = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
foreach (var star in system.Stars)
{
if (star.IsBlackHole) continue;
var rel = origin.Add(star.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters);
AddPoint(points, rel, star.Color * 1.3f, 3.2f);
}
@@ -748,6 +759,7 @@ internal sealed class UniverseRenderer : IDisposable
for (var i = 0; i < system.Stars.Count; i++)
{
var star = system.Stars[i];
if (star.IsBlackHole) continue;
_star.Set("uCenter", positions[i].ToVector3());
_star.Set("uRadius", (float)ApparentStarRadius(star, positions[i].Length));
_star.Set("uColor", star.Color);
@@ -756,6 +768,32 @@ internal sealed class UniverseRenderer : IDisposable
}
}
private RenderTarget DrawBlackHoles(FlyCamera camera, StarSystemData system, float far, RenderTarget source)
{
var star = system.Stars.FirstOrDefault(s => s.IsBlackHole);
if (star?.BlackHole is not { } hole) return source;
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var relative = center.Add(star.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters);
if (DVector3.Dot(relative, camera.Forward) < -hole.HorizonRadiusMeters * 32) return source;
_gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.Blend); _gl.DepthMask(false);
_blackHoleTarget.Bind(); _blackHole.Use(); SetCameraRay(_blackHole, camera);
BindTexture(0, source.Color); BindTexture(1, _scene.Depth);
var axis = System.Math.Abs(hole.DiskNormal.Y) < .9 ? new DVector3(0, 1, 0) : new DVector3(1, 0, 0);
var x = DVector3.Cross(hole.DiskNormal, axis).Normalized();
var z = DVector3.Cross(x, hole.DiskNormal).Normalized();
_blackHole.Set("uScene", 0); _blackHole.Set("uDepth", 1); _blackHole.Set("uFar", far);
_blackHole.Set("uCamera", (-relative / hole.HorizonRadiusMeters).ToVector3());
_blackHole.Set("uDiskX", x.ToVector3()); _blackHole.Set("uDiskZ", z.ToVector3());
_blackHole.Set("uDiskNormal", hole.DiskNormal.ToVector3());
_blackHole.Set("uHorizonMeters", (float)hole.HorizonRadiusMeters);
_blackHole.Set("uOuterRadius", (float)hole.DiskOuterRadiusInHorizons);
_blackHole.Set("uBrightness", (float)hole.DiskBrightness);
_blackHole.Set("uSteps", hole.RaySteps); _blackHole.Set("uTime", (float)_time);
_blackHole.Set("uSeed", (float)(star.Seed & 65535) * .01f);
_fullscreen.Draw();
return _blackHoleTarget;
}
private RenderTarget DrawAtmospheres(FlyCamera camera, StarSystemData system, float far)
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
@@ -948,7 +986,7 @@ internal sealed class UniverseRenderer : IDisposable
var z = DVector3.Dot(position, camera.Forward); var distance = position.Length;
var apparentRadius = ApparentStarRadius(star, distance);
var front = System.Math.Max(1, z - apparentRadius * 1.06);
var intensity = z > 0 ? glow.Attenuation(distance, star.LuminositySolar) : 0;
var intensity = z > 0 && !star.IsBlackHole ? glow.Attenuation(distance, star.LuminositySolar) : 0;
var uv = new Vector3((float)(.5 + DVector3.Dot(position, camera.Right) / (2 * System.Math.Max(1, z) * tan * aspect)),
(float)(.5 + DVector3.Dot(position, camera.Up) / (2 * System.Math.Max(1, z) * tan)),
(float)(System.Math.Log2(1 + front) / System.Math.Log2(1 + far)));
@@ -1044,6 +1082,7 @@ internal sealed class UniverseRenderer : IDisposable
_benchmarkOverlay.Dispose();
_gas.Dispose(); _rock.Dispose(); _rockPoints.Dispose(); _emptyRing.Dispose(); _gasInput.Dispose();
_sunGlow.Dispose(); _glareTarget.Dispose();
_blackHole.Dispose(); _blackHoleTarget.Dispose();
_ocean.Dispose();
_clouds.Dispose(); _cloudComposite.Dispose(); _cloudNoise.Dispose(); _emptyCloudDensity.Dispose(); _materials.Dispose(); _cloudRaw?.Dispose();
_scene.Dispose(); _scatterA.Dispose(); _scatterB.Dispose(); _bloomA.Dispose(); _bloomB.Dispose(); _noise.Dispose();