Svart hål
This commit is contained in:
13
README.md
13
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
118
src/Universe.App/Graphics/Shaders/black-hole.frag.glsl
Normal file
118
src/Universe.App/Graphics/Shaders/black-hole.frag.glsl
Normal 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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
31
src/Universe.Core/Generation/BlackHoleSettings.cs
Normal file
31
src/Universe.Core/Generation/BlackHoleSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
65
tests/Universe.Tests/BlackHoleTests.cs
Normal file
65
tests/Universe.Tests/BlackHoleTests.cs
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user