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

@@ -21,6 +21,7 @@ Solen och de åtta planeterna definieras i [KnownSolarSystem/solar-systems.json]
| Space / Ctrl | Upp / ner relativt kameran | | Space / Ctrl | Upp / ner relativt kameran |
| Mushjul | Logaritmisk hastighet, 0,1 m/s till 1 000 000 c | | Mushjul | Logaritmisk hastighet, 0,1 m/s till 1 000 000 c |
| Q | Mjuk flygning till närmaste planet | | Q | Mjuk flygning till närmaste planet |
| E | Mjuk flygning till närmaste svarta hål, även från en planets markyta |
| 1–9 | Flyg till planeten på motsvarande plats i `startup.planetHotkeys` i `config.json` | | 1–9 | Flyg till planeten på motsvarande plats i `startup.planetHotkeys` i `config.json` |
| F2 | Mjuk flygning till en bergsvy nära ytan; hastigheten sänks till 1 200 m/s | | F2 | Mjuk flygning till en bergsvy nära ytan; hastigheten sänks till 1 200 m/s |
| F3 | Närbild av närmaste stjärna | | F3 | Närbild av närmaste stjärna |
@@ -56,6 +57,18 @@ Välj **Debug** i P-menyn för en panel till vänster med asynkron GPU-tid för
Detta är en visuell realtidsprototyp. Banorna är statiska och terrängen använder proceduriell relief; ingen hydrologisk erosionssimulering eller atmosfär med multipel spridning beräknas. Detta är en visuell realtidsprototyp. Banorna är statiska och terrängen använder proceduriell relief; ingen hydrologisk erosionssimulering eller atmosfär med multipel spridning beräknas.
## Svarta hål
I proceduriella system ersätter ett svart hål stjärnan med 5 % sannolikhet. Samma seed och cell ger samma hål, massa och skivlutning. Kända system, inklusive Solsystemet, behåller sina definierade stjärnor. Planeterna placeras utanför ackretionsskivan och belyses av dess utstrålning.
Tryck **E** för en nio sekunders kameraresa till en sned vy över skivan. Sökningen expanderar cell för cell och jämför verkliga dubbelprecisionsavstånd; den avslutas när närmaste träff är säker, med en maximal sökradie på åtta celler. Kameran lämnar först planetens yta om den behöver det.
`blackHoles` i `config.json` styr `occurrenceChance` (0–1), `minimumMassSolar`, `maximumMassSolar`, `diskOuterRadiusInHorizons`, `diskBrightness` och `raySteps` (128–512). Sätt sannolikheten till 0 för att stänga av genereringen. Standardvärdena ger 200 000–600 000 solmassor, en skiva som sträcker sig till 14 horisontradier och 256 strålsteg.
Renderingen integrerar böjda ljusbanor i en förenklad Schwarzschildmodell och en tunn, animerad skiva. Horisontskugga, böjda bilder av skivans baksida, fotonring, dopplerförstärkning, gravitationell rödskiftning och HDR-bloom skapar utseendet. Se [Gralla, Holz och Wald: Black Hole Shadows, Photon Rings, and Lensing Rings](https://arxiv.org/abs/1906.00873) för bakgrunden till lins- och fotonringarna. Bakgrunden samplas från scenbilden när den finns i bild; riktningar bakom kameran får ett proceduriellt stjärnfält. Detta är en visuell realtidsmodell med en konstnärligt styrd emissionsskiva.
Debug-panelen visar `BLACK HOLE / LENSING` separat. Kör `Universe.App.exe --black-hole-report` för att rendera närmaste hål till `graphics-report/black-hole.png` och kontrollera shaderkompilering, OpenGL-fel och giltiga bildvärden.
## Projekt ## Projekt
- `src/Universe.Core`: koordinater, systemgenerering, noise, terräng, streaming och svept kollision. - `src/Universe.Core`: koordinater, systemgenerering, noise, terräng, streaming och svept kollision.

View File

@@ -2,6 +2,14 @@
"universeSeed": "0x554E495645525345", "universeSeed": "0x554E495645525345",
"cellSizeAu": 100, "cellSizeAu": 100,
"knownSolarSystemsFile": "KnownSolarSystem/solar-systems.json", "knownSolarSystemsFile": "KnownSolarSystem/solar-systems.json",
"blackHoles": {
"occurrenceChance": 0.05,
"minimumMassSolar": 200000,
"maximumMassSolar": 600000,
"diskOuterRadiusInHorizons": 14,
"diskBrightness": 4,
"raySteps": 256
},
"startup": { "startup": {
"systemId": "sol", "systemId": "sol",
"planetId": "earth", "planetId": "earth",

View File

@@ -13,6 +13,29 @@ namespace Universe.App.Graphics;
/// <summary>Offscreen tests of the production shaders; no desktop capture or mouse control.</summary> /// <summary>Offscreen tests of the production shaders; no desktop capture or mouse control.</summary>
internal static class GraphicsReport 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) public static void RunTerrainPerformance(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
{ {
var system = neighborhood.ActiveSystem; var system = neighborhood.ActiveSystem;

View File

@@ -36,6 +36,7 @@ internal sealed class PerformanceOverlay : IDisposable
AddMetric("ATMOSPHERE", "ATMOSPHERE"); AddMetric("ATMOSPHERE", "ATMOSPHERE");
AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE"); AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE");
AddMetric("SUN GLOW", "SUN GLOW"); AddMetric("SUN GLOW", "SUN GLOW");
AddMetric("BLACK HOLE / LENSING", "BLACK HOLE / LENSING");
AddMetric("BLOOM / POST", "BLOOM / POST"); AddMetric("BLOOM / POST", "BLOOM / POST");
rows.Add(("SHADOWS IN PLANET", "SEE PLANET MS", false)); rows.Add(("SHADOWS IN PLANET", "SEE PLANET MS", false));
rows.Add(("CPU SUBMIT", $"{Number(cpuMilliseconds)} 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 _rock;
private ShaderProgram _rockPoints; private ShaderProgram _rockPoints;
private ShaderProgram _sunGlow; private ShaderProgram _sunGlow;
private ShaderProgram _blackHole;
private ShaderProgram _ocean; private ShaderProgram _ocean;
private ShaderProgram _clouds; private ShaderProgram _clouds;
private ShaderProgram _cloudComposite; private ShaderProgram _cloudComposite;
@@ -53,6 +54,7 @@ internal sealed class UniverseRenderer : IDisposable
private RenderTarget _scene = null!; private RenderTarget _scene = null!;
private RenderTarget _gasInput = null!; private RenderTarget _gasInput = null!;
private RenderTarget _glareTarget = null!; private RenderTarget _glareTarget = null!;
private RenderTarget _blackHoleTarget = null!;
private RenderTarget _scatterA = null!; private RenderTarget _scatterA = null!;
private RenderTarget _scatterB = null!; private RenderTarget _scatterB = null!;
private RenderTarget _bloomA = null!; private RenderTarget _bloomA = null!;
@@ -107,6 +109,7 @@ internal sealed class UniverseRenderer : IDisposable
_rock = Program("rock.vert", "rock.frag"); _rock = Program("rock.vert", "rock.frag");
_rockPoints = Program("rock-point.vert", "rock-point.frag"); _rockPoints = Program("rock-point.vert", "rock-point.frag");
_sunGlow = Program("fullscreen.vert", "sun-glow.frag"); _sunGlow = Program("fullscreen.vert", "sun-glow.frag");
_blackHole = Program("fullscreen.vert", "black-hole.frag");
_ocean = Program("fullscreen.vert", "ocean.frag"); _ocean = Program("fullscreen.vert", "ocean.frag");
_clouds = Program("fullscreen.vert", "cloud.frag"); _clouds = Program("fullscreen.vert", "cloud.frag");
_cloudComposite = Program("fullscreen.vert", "cloud-composite.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"), 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","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","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)); replacements.Add(Program(pair.Item1, pair.Item2));
} }
catch (Exception error) catch (Exception error)
@@ -186,6 +189,7 @@ internal sealed class UniverseRenderer : IDisposable
_sunGlow.Dispose(); _sunGlow = replacements[12]; _sunGlow.Dispose(); _sunGlow = replacements[12];
_ocean.Dispose(); _ocean = replacements[13]; _ocean.Dispose(); _ocean = replacements[13];
_clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15]; _clouds.Dispose(); _clouds = replacements[14]; _cloudComposite.Dispose(); _cloudComposite = replacements[15];
_blackHole.Dispose(); _blackHole = replacements[16];
} }
public void SetActiveSystem(SectorCoordinate coordinate) public void SetActiveSystem(SectorCoordinate coordinate)
@@ -211,9 +215,11 @@ internal sealed class UniverseRenderer : IDisposable
_height = System.Math.Max(height, 1); _height = System.Math.Max(height, 1);
_cloudRaw?.Dispose(); _cloudRaw = null; _cloudRaw?.Dispose(); _cloudRaw = null;
_scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.Dispose(); _scene?.Dispose(); _gasInput?.Dispose(); _glareTarget?.Dispose(); _scatterA?.Dispose(); _scatterB?.Dispose(); _bloomA?.Dispose(); _bloomB?.Dispose();
_blackHoleTarget?.Dispose();
_scene = new(_gl, _width, _height, true); _scene = new(_gl, _width, _height, true);
_gasInput = new(_gl, _width, _height, true); _gasInput = new(_gl, _width, _height, true);
_glareTarget = new(_gl, _width, _height); _glareTarget = new(_gl, _width, _height);
_blackHoleTarget = new(_gl, _width, _height);
_scatterA = new(_gl, _width, _height); _scatterA = new(_gl, _width, _height);
_scatterB = new(_gl, _width, _height); _scatterB = new(_gl, _width, _height);
_bloomA = new(_gl, System.Math.Max(1, _width / 4), System.Math.Max(1, _height / 4)); _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("uNoise", 4); _sky.Set("uTime", (float)_time);
_sky.Set("uLightCount", system.Stars.Count); _sky.Set("uLightCount", system.Stars.Count);
_sky.SetVectorArray("uStarDirections", starPositions.Select(p => p.Normalized().ToVector3()).ToArray()); _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()); _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"); _gpuProfiler.BeginPass("SKY / STARFIELD");
_fullscreen.Draw(); _fullscreen.Draw();
@@ -295,6 +301,10 @@ internal sealed class UniverseRenderer : IDisposable
composed = DrawClouds(camera, system, far, composed); composed = DrawClouds(camera, system, far, composed);
_gpuProfiler.EndPass(); _gpuProfiler.EndPass();
Mark("clouds"); Mark("clouds");
_gpuProfiler.BeginPass("BLACK HOLE / LENSING");
composed = DrawBlackHoles(camera, system, far, composed);
_gpuProfiler.EndPass();
Mark("black hole lensing");
_gpuProfiler.BeginPass("SUN GLOW"); _gpuProfiler.BeginPass("SUN GLOW");
DrawSunGlow(camera, system, starPositions, far, composed); DrawSunGlow(camera, system, starPositions, far, composed);
_gpuProfiler.EndPass(); _gpuProfiler.EndPass();
@@ -380,6 +390,7 @@ internal sealed class UniverseRenderer : IDisposable
var origin = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters); var origin = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
foreach (var star in system.Stars) foreach (var star in system.Stars)
{ {
if (star.IsBlackHole) continue;
var rel = origin.Add(star.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters); var rel = origin.Add(star.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters);
AddPoint(points, rel, star.Color * 1.3f, 3.2f); 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++) for (var i = 0; i < system.Stars.Count; i++)
{ {
var star = system.Stars[i]; var star = system.Stars[i];
if (star.IsBlackHole) continue;
_star.Set("uCenter", positions[i].ToVector3()); _star.Set("uCenter", positions[i].ToVector3());
_star.Set("uRadius", (float)ApparentStarRadius(star, positions[i].Length)); _star.Set("uRadius", (float)ApparentStarRadius(star, positions[i].Length));
_star.Set("uColor", star.Color); _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) private RenderTarget DrawAtmospheres(FlyCamera camera, StarSystemData system, float far)
{ {
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters); 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 z = DVector3.Dot(position, camera.Forward); var distance = position.Length;
var apparentRadius = ApparentStarRadius(star, distance); var apparentRadius = ApparentStarRadius(star, distance);
var front = System.Math.Max(1, z - apparentRadius * 1.06); 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)), 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)(.5 + DVector3.Dot(position, camera.Up) / (2 * System.Math.Max(1, z) * tan)),
(float)(System.Math.Log2(1 + front) / System.Math.Log2(1 + far))); (float)(System.Math.Log2(1 + front) / System.Math.Log2(1 + far)));
@@ -1044,6 +1082,7 @@ internal sealed class UniverseRenderer : IDisposable
_benchmarkOverlay.Dispose(); _benchmarkOverlay.Dispose();
_gas.Dispose(); _rock.Dispose(); _rockPoints.Dispose(); _emptyRing.Dispose(); _gasInput.Dispose(); _gas.Dispose(); _rock.Dispose(); _rockPoints.Dispose(); _emptyRing.Dispose(); _gasInput.Dispose();
_sunGlow.Dispose(); _glareTarget.Dispose(); _sunGlow.Dispose(); _glareTarget.Dispose();
_blackHole.Dispose(); _blackHoleTarget.Dispose();
_ocean.Dispose(); _ocean.Dispose();
_clouds.Dispose(); _cloudComposite.Dispose(); _cloudNoise.Dispose(); _emptyCloudDensity.Dispose(); _materials.Dispose(); _cloudRaw?.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(); _scene.Dispose(); _scatterA.Dispose(); _scatterB.Dispose(); _bloomA.Dispose(); _bloomB.Dispose(); _noise.Dispose();

View File

@@ -272,7 +272,7 @@ public sealed class FlyCamera
public void RequestStarView(IEnumerable<StarSystemData> systems) public void RequestStarView(IEnumerable<StarSystemData> systems)
{ {
var nearest = systems.SelectMany(system => system.Stars.Select(star => (System: system, Star: star))) var nearest = systems.SelectMany(system => system.Stars.Where(s => !s.IsBlackHole).Select(star => (System: system, Star: star)))
.MinBy(body => World(body.System, body.Star.PositionMeters).RelativeTo(Position, CellSizeMeters).LengthSquared); .MinBy(body => World(body.System, body.Star.PositionMeters).RelativeTo(Position, CellSizeMeters).LengthSquared);
if (nearest.Star is null) return; if (nearest.Star is null) return;
var bodyPosition = World(nearest.System, nearest.Star.PositionMeters); var bodyPosition = World(nearest.System, nearest.Star.PositionMeters);
@@ -280,6 +280,28 @@ public sealed class FlyCamera
BeginFlight(bodyPosition, radial, nearest.Star.RadiusMeters * 2.55, nearest.Star.RadiusMeters * 5, -radial, new(0, 1, 0), 10_000_000); BeginFlight(bodyPosition, radial, nearest.Star.RadiusMeters * 2.55, nearest.Star.RadiusMeters * 5, -radial, new(0, 1, 0), 10_000_000);
} }
public void RequestBlackHoleView(StarSystemData system, StarData star, IEnumerable<StarSystemData> departureSystems)
{
if (star.BlackHole is not { } hole) return;
var body = World(system, star.PositionMeters);
var equator = Perpendicular(hole.DiskNormal);
var radial = (equator * System.Math.Cos(.075) + hole.DiskNormal * System.Math.Sin(.075)).Normalized();
var up = RotateAround(hole.DiskNormal, -radial, .18);
Departure? departure = null;
var escape = departureSystems.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet).Select(p => (System: s, Planet: p)))
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length / p.Planet.RadiusMeters);
if (escape.Planet is { } solid)
{
var origin = World(escape.System, solid.PositionMeters);
var start = Position.RelativeTo(origin, CellSizeMeters);
if (start.Length < solid.MaximumTerrainRadiusMeters * 1.1)
departure = new(origin, start.Normalized(), body.RelativeTo(origin, CellSizeMeters).Normalized(),
start.Length, solid.MaximumTerrainRadiusMeters * 1.12);
}
BeginFlight(body, radial, star.RadiusMeters * 26, star.RadiusMeters * 70, -radial, up,
star.RadiusMeters * .08, duration: 9, fieldOfView: 32 * System.Math.PI / 180, departure: departure);
}
private UniversePosition World(StarSystemData system, DVector3 offset) => UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters).Add(offset, CellSizeMeters); private UniversePosition World(StarSystemData system, DVector3 offset) => UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters).Add(offset, CellSizeMeters);
private void BeginFlight(UniversePosition body, DVector3 radial, double finalRadius, double approachRadius, DVector3 forward, DVector3 up, double speed, private void BeginFlight(UniversePosition body, DVector3 radial, double finalRadius, double approachRadius, DVector3 forward, DVector3 up, double speed,

View File

@@ -52,7 +52,7 @@ public sealed class UniverseApplication : IDisposable
_planetHotkeys = configuration.Startup.PlanetHotkeys; _planetHotkeys = configuration.Startup.PlanetHotkeys;
var catalogPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.KnownSolarSystemsFile)); var catalogPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.KnownSolarSystemsFile));
var catalog = KnownSolarSystemCatalog.Load(catalogPath, configuration.CellSizeMeters); var catalog = KnownSolarSystemCatalog.Load(catalogPath, configuration.CellSizeMeters);
_generator = new(configuration.Seed, configuration.CellSizeMeters, catalog); _generator = new(configuration.Seed, configuration.CellSizeMeters, catalog, configuration.BlackHoles);
_streamer = new(_generator); _streamer = new(_generator);
var initialSystem = configuration.ResolveStartup(catalog).System; var initialSystem = configuration.ResolveStartup(catalog).System;
_homeSystem = catalog.GetById("sol"); _homeSystem = catalog.GetById("sol");
@@ -72,7 +72,7 @@ public sealed class UniverseApplication : IDisposable
var options = WindowOptions.Default with var options = WindowOptions.Default with
{ {
Title = "Universe | loading", Title = "Universe | loading",
IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--graphics-report" or "--scene-report" or "--ocean-report" or "--cloud-report" or "--terrain-report" or "--menu-report" or "--terrain-perf-report"), IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--graphics-report" or "--scene-report" or "--ocean-report" or "--cloud-report" or "--terrain-report" or "--menu-report" or "--terrain-perf-report" or "--black-hole-report"),
Size = new Vector2D<int>(1440, 900), Size = new Vector2D<int>(1440, 900),
VSync = true, VSync = true,
PreferredDepthBufferBits = 24, PreferredDepthBufferBits = 24,
@@ -93,6 +93,14 @@ public sealed class UniverseApplication : IDisposable
var gl = _window.CreateOpenGL(); var gl = _window.CreateOpenGL();
_input = _window.CreateInput(); _input = _window.CreateInput();
_renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y); _renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y);
if (Environment.GetCommandLineArgs().Contains("--black-hole-report"))
{
var target = _streamer.FindNearestBlackHole(_camera.Position)
?? throw new InvalidOperationException("No black hole found; check blackHoles.occurrenceChance.");
GraphicsReport.RunBlackHole(gl, _renderer, _streamer.Load(target.System.Coordinate), target.Hole,
_generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y);
_window.Close(); return;
}
if (Environment.GetCommandLineArgs().Contains("--terrain-perf-report")) if (Environment.GetCommandLineArgs().Contains("--terrain-perf-report"))
{ GraphicsReport.RunTerrainPerformance(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; } { GraphicsReport.RunTerrainPerformance(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
if (Environment.GetCommandLineArgs().Contains("--menu-report")) if (Environment.GetCommandLineArgs().Contains("--menu-report"))
@@ -235,6 +243,16 @@ public sealed class UniverseApplication : IDisposable
if (_benchmark?.IsRunning == true && key != Key.F1) return; if (_benchmark?.IsRunning == true && key != Key.F1) return;
if (key == Key.Escape) if (key == Key.Escape)
_window.Close(); _window.Close();
else if (key == Key.E)
{
var target = _streamer.FindNearestBlackHole(_camera.Position);
if (target is { } hole)
{
_player = null;
_camera.RequestBlackHoleView(hole.System, hole.Hole, _neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
}
else _window.Title = "Universe | No black hole found in the search area";
}
else if (_player is not null && key != Key.F1) return; else if (_player is not null && key != Key.F1) return;
else if (key == Key.Q) else if (key == Key.Q)
_camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem)); _camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));

View File

@@ -23,6 +23,7 @@ public sealed record EngineConfiguration
public double CellSizeAu { get; init; } = 100; public double CellSizeAu { get; init; } = 100;
public string KnownSolarSystemsFile { get; init; } = "KnownSolarSystem/solar-systems.json"; public string KnownSolarSystemsFile { get; init; } = "KnownSolarSystem/solar-systems.json";
public StartupConfiguration Startup { get; init; } = new(); public StartupConfiguration Startup { get; init; } = new();
public BlackHoleGenerationSettings BlackHoles { get; init; } = new();
public double CellSizeMeters => CellSizeAu * UniverseScale.AstronomicalUnitMeters; public double CellSizeMeters => CellSizeAu * UniverseScale.AstronomicalUnitMeters;
public ulong Seed => UniverseSeed.StartsWith("0x", StringComparison.OrdinalIgnoreCase) public ulong Seed => UniverseSeed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ulong.Parse(UniverseSeed.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture) ? ulong.Parse(UniverseSeed.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture)
@@ -42,6 +43,7 @@ public sealed record EngineConfiguration
public static EngineConfiguration Load(string path) public static EngineConfiguration Load(string path)
{ {
var settings = JsonSettings.Read<EngineConfiguration>(path); var settings = JsonSettings.Read<EngineConfiguration>(path);
(settings.BlackHoles ?? throw new InvalidDataException("config.json: blackHoles cannot be null.")).Validate();
if (string.IsNullOrWhiteSpace(settings.UniverseSeed)) throw new InvalidDataException("config.json: universeSeed cannot be empty."); if (string.IsNullOrWhiteSpace(settings.UniverseSeed)) throw new InvalidDataException("config.json: universeSeed cannot be empty.");
try { _ = settings.Seed; } try { _ = settings.Seed; }
catch (Exception error) when (error is FormatException or OverflowException or ArgumentNullException) catch (Exception error) when (error is FormatException or OverflowException or ArgumentNullException)

View File

@@ -0,0 +1,31 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
public sealed record BlackHoleGenerationSettings
{
public double OccurrenceChance { get; init; } = .05;
public double MinimumMassSolar { get; init; } = 200_000;
public double MaximumMassSolar { get; init; } = 600_000;
public double DiskOuterRadiusInHorizons { get; init; } = 14;
public double DiskBrightness { get; init; } = 4;
public int RaySteps { get; init; } = 256;
public void Validate()
{
if (!double.IsFinite(OccurrenceChance) || OccurrenceChance is < 0 or > 1
|| !double.IsFinite(MinimumMassSolar) || MinimumMassSolar < 1
|| !double.IsFinite(MaximumMassSolar) || MaximumMassSolar < MinimumMassSolar || MaximumMassSolar > 10_000_000
|| !double.IsFinite(DiskOuterRadiusInHorizons) || DiskOuterRadiusInHorizons is < 5 or > 30
|| !double.IsFinite(DiskBrightness) || DiskBrightness is < .01 or > 100
|| RaySteps is < 128 or > 512)
throw new InvalidDataException("config.json: invalid blackHoles settings. RaySteps must be 128–512 and occurrenceChance 0–1.");
}
}
public sealed record BlackHoleData(double MassSolar, DVector3 DiskNormal, double DiskOuterRadiusInHorizons,
double DiskBrightness, int RaySteps)
{
// Schwarzschild radius 2GM/c², in metres per solar mass.
public double HorizonRadiusMeters => MassSolar * 2953.25;
}

View File

@@ -14,6 +14,8 @@ public sealed record StarData(
{ {
public string Id { get; init; } = $"star-{Index}"; public string Id { get; init; } = $"star-{Index}";
public StarGlowSettings Glow { get; init; } = new(); public StarGlowSettings Glow { get; init; } = new();
public BlackHoleData? BlackHole { get; init; }
public bool IsBlackHole => BlackHole is not null;
} }
public sealed record PlanetData( public sealed record PlanetData(

View File

@@ -15,17 +15,21 @@ public sealed class UniverseGenerator
private static readonly Vector3[] AtmosphereColors = private static readonly Vector3[] AtmosphereColors =
[new(0.13f, 0.32f, 1f), new(0.16f, 0.42f, 1f), new(0.09f, 0.22f, 0.88f), new(0.80f, 0.48f, 0.25f)]; [new(0.13f, 0.32f, 1f), new(0.16f, 0.42f, 1f), new(0.09f, 0.22f, 0.88f), new(0.80f, 0.48f, 0.25f)];
public UniverseGenerator(ulong universeSeed, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters, KnownSolarSystemCatalog? knownSystems = null) public UniverseGenerator(ulong universeSeed, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters, KnownSolarSystemCatalog? knownSystems = null,
BlackHoleGenerationSettings? blackHoles = null)
{ {
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0) if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters)); throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
UniverseSeed = universeSeed; UniverseSeed = universeSeed;
CellSizeMeters = cellSizeMeters; CellSizeMeters = cellSizeMeters;
_knownSystems = knownSystems; _knownSystems = knownSystems;
BlackHoles = blackHoles ?? new();
BlackHoles.Validate();
} }
public ulong UniverseSeed { get; } public ulong UniverseSeed { get; }
public double CellSizeMeters { get; } public double CellSizeMeters { get; }
public BlackHoleGenerationSettings BlackHoles { get; }
public StarSystemData Generate(SectorCoordinate coordinate) public StarSystemData Generate(SectorCoordinate coordinate)
{ {
@@ -39,6 +43,9 @@ public sealed class UniverseGenerator
ProceduralNoise.Sample(noiseOffset + new DVector3(17.7, 6.3, 3.1)), ProceduralNoise.Sample(noiseOffset + new DVector3(17.7, 6.3, 3.1)),
ProceduralNoise.Sample(noiseOffset + new DVector3(2.6, 22.3, 11.8))) * (CellSizeMeters * 0.18); ProceduralNoise.Sample(noiseOffset + new DVector3(2.6, 22.3, 11.8))) * (CellSizeMeters * 0.18);
var starCount = random.NextDouble() < 0.46 ? 2 : 1; var starCount = random.NextDouble() < 0.46 ? 2 : 1;
var holeRandom = new DeterministicRandom(DeterministicRandom.Mix(systemSeed ^ 0x424C41434B484F4CUL));
var hasBlackHole = holeRandom.NextDouble() < BlackHoles.OccurrenceChance;
if (hasBlackHole) starCount = 1;
var binaryAxis = UnitVector(ref random); var binaryAxis = UnitVector(ref random);
var separation = (0.025 + random.NextDouble() * 0.17) * UniverseScale.AstronomicalUnitMeters; var separation = (0.025 + random.NextDouble() * 0.17) * UniverseScale.AstronomicalUnitMeters;
var massRatio = 0.25 + random.NextDouble() * 0.6; var massRatio = 0.25 + random.NextDouble() * 0.6;
@@ -53,9 +60,20 @@ public sealed class UniverseGenerator
{ Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}" }); { Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}" });
} }
if (hasBlackHole)
{
var mass = BlackHoles.MinimumMassSolar + holeRandom.NextDouble() * (BlackHoles.MaximumMassSolar - BlackHoles.MinimumMassSolar);
var hole = new BlackHoleData(mass, UnitVector(ref holeRandom), BlackHoles.DiskOuterRadiusInHorizons,
BlackHoles.DiskBrightness, BlackHoles.RaySteps);
stars[0] = stars[0] with { Name = $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} Black Hole", RadiusMeters = hole.HorizonRadiusMeters,
LuminositySolar = .8, Color = new(1f, .73f, .44f), BlackHole = hole };
}
var planetCount = random.NextInt(5, 10); var planetCount = random.NextInt(5, 10);
var planets = new List<PlanetData>(planetCount); var planets = new List<PlanetData>(planetCount);
var orbitAu = 0.38 + random.NextDouble() * 0.48; var orbitAu = 0.38 + random.NextDouble() * 0.48;
if (stars[0].BlackHole is { } centralHole)
orbitAu = System.Math.Max(orbitAu, centralHole.HorizonRadiusMeters * centralHole.DiskOuterRadiusInHorizons * 2.5 / UniverseScale.AstronomicalUnitMeters);
var planeNormal = UnitVector(ref random); var planeNormal = UnitVector(ref random);
var barycentreExtent = System.Math.Max(System.Math.Abs(barycentre.X), System.Math.Max(System.Math.Abs(barycentre.Y), System.Math.Abs(barycentre.Z))); var barycentreExtent = System.Math.Max(System.Math.Abs(barycentre.X), System.Math.Max(System.Math.Abs(barycentre.Y), System.Math.Abs(barycentre.Z)));
var maximumOrbit = (CellSizeMeters * 0.47 - barycentreExtent) / UniverseScale.AstronomicalUnitMeters / 1.32; var maximumOrbit = (CellSizeMeters * 0.47 - barycentreExtent) / UniverseScale.AstronomicalUnitMeters / 1.32;

View File

@@ -9,6 +9,38 @@ public sealed class UniverseStreamer(UniverseGenerator generator)
{ {
private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = []; private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = [];
public (StarSystemData System, StarData Hole)? FindNearestBlackHole(UniversePosition position)
{
if (generator.BlackHoles.OccurrenceChance <= 0) return null;
(StarSystemData System, StarData Hole)? nearest = null;
var bestDistance = double.PositiveInfinity;
var cell = generator.CellSizeMeters;
for (var radius = 0; radius <= 8; radius++)
{
for (var z = -radius; z <= radius; z++)
for (var y = -radius; y <= radius; y++)
for (var x = -radius; x <= radius; x++)
{
if (System.Math.Max(System.Math.Abs(x), System.Math.Max(System.Math.Abs(y), System.Math.Abs(z))) != radius) continue;
if (!position.Sector.TryOffset(x, y, z, out var coordinate)) continue;
var system = GetOrCreate(coordinate);
var center = UniversePosition.AtCellCenter(coordinate, cell);
foreach (var hole in system.Stars.Where(s => s.IsBlackHole))
{
var distance = center.Add(hole.PositionMeters, cell).RelativeTo(position, cell).Length;
if (distance >= bestDistance) continue;
nearest = (system, hole); bestDistance = distance;
}
}
// Any unvisited cell lies beyond at least one face of this cube.
var p = position.LocalOffset;
var edge = System.Math.Min(System.Math.Min(p.X, cell - p.X),
System.Math.Min(System.Math.Min(p.Y, cell - p.Y), System.Math.Min(p.Z, cell - p.Z)));
if (bestDistance < radius * cell + edge) break;
}
return nearest;
}
public UniverseNeighborhood Load(SectorCoordinate activeCoordinate) public UniverseNeighborhood Load(SectorCoordinate activeCoordinate)
{ {
var active = GetOrCreate(activeCoordinate); var active = GetOrCreate(activeCoordinate);

View File

@@ -0,0 +1,65 @@
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Tests;
public sealed class BlackHoleTests
{
[Fact]
public void ReplacementIsDeterministicAndPlanetsClearTheDisk()
{
var generator = new UniverseGenerator(123, blackHoles: new() { OccurrenceChance = 1 });
var first = generator.Generate(new(-2, 1, 3));
var second = generator.Generate(first.Coordinate);
var hole = Assert.Single(first.Stars);
Assert.True(hole.IsBlackHole);
Assert.Equal(hole, Assert.Single(second.Stars));
Assert.Equal(hole.RadiusMeters, hole.BlackHole!.HorizonRadiusMeters);
Assert.All(first.Planets, p => Assert.True((p.PositionMeters - hole.PositionMeters).Length >
hole.RadiusMeters * hole.BlackHole.DiskOuterRadiusInHorizons));
}
[Fact]
public void NearestSearchMatchesWideExhaustiveSearchAndFlightReachesTheHole()
{
var generator = new UniverseGenerator(0x554E495645525345UL);
var streamer = new UniverseStreamer(generator);
var start = UniversePosition.AtCellCenter(new(0, 0, 0), generator.CellSizeMeters);
var found = streamer.FindNearestBlackHole(start);
Assert.True(found.HasValue);
var target = found!.Value;
var exhaustive = generator.GenerateNeighborhood(start.Sector, 3)
.SelectMany(s => s.Stars.Where(h => h.IsBlackHole).Select(h => (System: s, Hole: h)))
.MinBy(b => UniversePosition.AtCellCenter(b.System.Coordinate, generator.CellSizeMeters)
.Add(b.Hole.PositionMeters, generator.CellSizeMeters).RelativeTo(start, generator.CellSizeMeters).Length);
Assert.Equal(exhaustive.Hole.Id, target.Hole.Id);
var neighborhood = streamer.Load(start.Sector);
var groundPlanet = neighborhood.ActiveSystem.Planets[0];
var groundBody = start.Add(groundPlanet.PositionMeters, generator.CellSizeMeters);
var groundRadial = new DVector3(1, .3, -.2).Normalized();
var groundPosition = groundBody.Add(groundRadial * (TerrainHeight.Sample(groundPlanet, groundRadial) + 1.8), generator.CellSizeMeters);
var camera = new FlyCamera(groundPosition, generator.CellSizeMeters);
camera.RequestBlackHoleView(target.System, target.Hole, neighborhood.NearbySystems.Append(neighborhood.ActiveSystem));
for (var i = 0; i < 650; i++)
{
camera.Update(1d / 60, DVector3.Zero, neighborhood);
if (camera.Position.Sector != neighborhood.ActiveSystem.Coordinate) neighborhood = streamer.Load(camera.Position.Sector);
}
var body = UniversePosition.AtCellCenter(target.System.Coordinate, generator.CellSizeMeters)
.Add(target.Hole.PositionMeters, generator.CellSizeMeters);
var relative = body.RelativeTo(camera.Position, generator.CellSizeMeters);
Assert.False(camera.IsFocusing);
Assert.InRange(relative.Length / target.Hole.RadiusMeters, 25.99, 26.01);
Assert.InRange(DVector3.Dot(relative.Normalized(), camera.Forward), .999, 1.001);
}
[Fact]
public void ZeroChanceDisablesGenerationAndSearch()
{
var generator = new UniverseGenerator(123, blackHoles: new() { OccurrenceChance = 0 });
Assert.All(generator.GenerateNeighborhood(new()), s => Assert.All(s.Stars, h => Assert.False(h.IsBlackHole)));
Assert.Null(new UniverseStreamer(generator).FindNearestBlackHole(UniversePosition.AtCellCenter(new(), generator.CellSizeMeters)));
}
}