galaxer och motion blur

This commit is contained in:
2026-11-01 15:46:28 +01:00
parent 8274a20001
commit 9d44d14cc9
39 changed files with 1502 additions and 72 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

25
KnownGalaxies.json Normal file
View File

@@ -0,0 +1,25 @@
{
"galaxies": [
{
"id": "milky-way",
"name": "Vintergatan",
"region": [0, 0, 0],
"centerOffsetLightYears": [-26000, 0, 0],
"radiusLightYears": 50000,
"thicknessLightYears": 1800,
"axialTiltDegrees": 0,
"rotationDegrees": 0,
"spiralArms": 4,
"densityGamma": 0.7,
"texture": "Content/Galaxies/milky-way.png",
"nebulae": [
{
"id": "milky-way:hourglass",
"positionLightYears": [26000, 0, 300],
"radiusLightYears": 80,
"texture": "Content/Nebulae/hourglass.jpg"
}
]
}
]
}

View File

@@ -17,9 +17,9 @@ Solen och de åtta planeterna definieras i [KnownSolarSystem/solar-systems.json]
| Kontroll | Funktion | | Kontroll | Funktion |
| --- | --- | | --- | --- |
| WASD + mus | Fri flygning och riktning | | WASD + mus | Fri flygning och riktning |
| P | Öppna/stäng menyn. Välj Play, Free Camera, Debug, Benchmark Earth, Benchmark Unknown eller Quit med musen eller piltangenter och Enter | | P | Öppna/stäng menyn. Välj Play, Free Camera, Debug, Benchmark Earth, Benchmark Unknown, Galaxy Journey eller Quit med musen eller piltangenter och Enter |
| 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, från 0,1 m/s upp till galaktisk FTL-fart (10^15 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 | | 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` |
@@ -30,6 +30,8 @@ Solen och de åtta planeterna definieras i [KnownSolarSystem/solar-systems.json]
| F5 | Ladda om shaders; Debug läser direkt från shaderfilerna | | F5 | Ladda om shaders; Debug läser direkt från shaderfilerna |
| F6 | Flyg till en solbelyst havsvy nära vattenytan; hastigheten sänks till 10 m/s | | F6 | Flyg till en solbelyst havsvy nära vattenytan; hastigheten sänks till 10 m/s |
| F7 | Flyg till en vy ovanför närmaste molnlager; hastigheten sänks till 100 m/s | | F7 | Flyg till en vy ovanför närmaste molnlager; hastigheten sänks till 100 m/s |
| F8 | Flyg ut till en översiktsvy av den aktuella galaxen |
| F9 | Flyg till närmaste nebulosa i galaxen |
| Esc | Stäng pausmenyn, eller avsluta när menyn är stängd | | Esc | Stäng pausmenyn, eller avsluta när menyn är stängd |
**Play** växlar till förstapersonsläge. WASD går längs marken, musen ser sig omkring, Space hoppar och Ctrl hukar. Spelaren dras mot närmaste fasta planet och orienteras efter dess lokala gravitation. Från stora avstånd används en snabbare inflygning för att landningen ska vara spelbar. **Free Camera** återställer fri flygning. Pausmenyn stoppar rörelsen medan den är öppen. Gasjättar har ingen fast mark och väljs därför inte som landningsmål. **Play** växlar till förstapersonsläge. WASD går längs marken, musen ser sig omkring, Space hoppar och Ctrl hukar. Spelaren dras mot närmaste fasta planet och orienteras efter dess lokala gravitation. Från stora avstånd används en snabbare inflygning för att landningen ska vara spelbar. **Free Camera** återställer fri flygning. Pausmenyn stoppar rörelsen medan den är öppen. Gasjättar har ingen fast mark och väljs därför inte som landningsmål.
@@ -57,11 +59,41 @@ 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.
## Galaxer och nebulosor
`KnownGalaxies.json` definierar Vintergatan. Den bifogade galaxbilden finns i `Content/Galaxies/milky-way.png` och används både för utseende och faktisk systemtäthet: luminans × alpha, formad av `densityGamma`, blir sannolikheten att en seedad stjärnkandidat finns. Svarta eller genomskinliga områden får inga kandidater. Solsystemet är alltid fastlagt i sin kända cell. Byt bildsökvägen i `texture`, eller utelämna den för en automatiskt genererad spiralgalax.
Varje galaxregion är normalt en miljon ljusår bred. Proceduriella galaxer får egna radier, lutningar, spiralarmar och nebulosor från universums seed. `nebulae` i en känd galax kan ange `id`, `positionLightYears` relativt galaxcentrum, `radiusLightYears` och en valfri bild. Systemdata har `GalaxyId` och ett `NebulaId` för närmaste nebulosagrupp; gruppmedlemskapet innebär inte att varje planet ligger inne i det synliga gasmolnet.
`GalaxyAddress` delar adressen i en galaxregion, en lokal solsystemscell och lokala meter. De två första delarna är heltal. Befintliga `UniversePosition` använder fortfarande det globala heltalsrutnätet, så lokala kameror, terräng och kollision behöver aldrig en enda gigantisk flyttalsposition. Avlägsna objekt omvandlas till kamerarelativa koordinater före GPU-rendering.
`galaxies` i `config.json` styr regionstorlek, `systemSpacingLightYears`, `galaxyStarBudget`, `localStarBudget` och `volumeSteps`. Budgeterna begränsar representativa stjärnpunkter och volymsteg, inte universums storlek. Varje stjärnpunkt härleds från en verklig seedad systemkandidat. Glesa systemceller mellan stjärnorna är tomma. Närliggande system laddas först på solsystemsnivå; bakgrundspunkter, galaxvolymer och nebulosavolymer ersätter dyr planetgeometri på större skalor. Stjärnmoln beräknas på högst två bakgrundsjobb, högst en färdig punktmesh laddas upp per bildruta, och högst tre detaljerade system hålls kvar på GPU:n.
Mushjulet kan nu skala fri flyghastighet upp till galaktiska avstånd. F8 och F9 ger översiktsvyer. Vyn växlar mjukt mellan galax-/nebulosapunkter, volymer, individuella systemstjärnor och lokala planeter.
### Stjärnornas rörelsespår
Stjärnsprites ritas som mjuka ljusstreck från föregående till aktuell skärmposition. Både kamerans förflyttning, rotation och gradvisa synfältsändring ingår; beräkningen använder kamerarelativa dubbelprecisionspositioner även mellan galaxer. Strecken växer med skärmrörelsen och ger fartkänsla när stjärnor passerar. Stilla stjärnor blir mjuka punkter igen utan kvarhängande efterbilder. Galax-/nebulosamarkörer, planetmeshar och gränssnitt ingår inte i effekten.
`starMotionBlur` i `config.json` styr `enabled`, `shutterFraction` (hur stor del av bildintervallet som bildar spår), `maximumTrailPixels`, `minimumWidthPixels`, `movingBrightness`, `historyResetSeconds` och `starFadeSeconds`. Standardinställningen är 90 % av bildintervallet och högst 180 bildpunkters spår. Kamerahopp, återställda benchmarkvyer, paus, fönsterstorleksbyte och långa bildintervall nollställer historiken. F5 laddar även om stjärnornas vertex-, geometry- och fragmentshaders.
Det lokala stjärnurvalet förankras över 32 stjärngridblock och tonas mellan färdiga urval under 0,3 sekunder. Det gamla urvalet visas tills ersättaren är klar. Detta minskar blinkningar vid laddning utöver den jämnare pixelprofilen och rörelsespåren.
`Universe.App.exe --star-trails-report` kör ett OpenGL-test med syntetiska stjärnor: rörelse måste ge längre streck än stillastående, den konfigurerade maxlängden kontrolleras och stopp/kamerahopp måste ge rena punktbilder. Bilderna sparas som `graphics-report/star-motion-blur.png` och `star-motion-stopped.png`.
### Galaxresetest
Välj **Galaxy Journey** i P-menyn. Den fasta 94-sekundersbanan lämnar Jorden, visar Vintergatan, flyger genom tomrummet till galaxregion `(1,0,0)`, går in i ett deterministiskt utvalt solsystem och landar på dess största fasta huvudplanet, 1,8 m över terrängen. Därefter återställs Jordvyn. Båda ändsystemens grova geometri och kartor förbereds innan mätningen börjar.
Grafen visar FPS över resans tid. Efteråt visas PASS eller FAIL och `benchmark-results/galaxy-journey-.../` innehåller `frames.csv` och `summary.json` med bildrutetider, planet-/system-/galax-id och statistik per fas. En fas måste uppfylla både `minimumBenchmarkFps` för sin 1 %-låga FPS och `maximumBenchmarkFrameMilliseconds` för sin långsammaste bildruta. Standardgränserna är 45 FPS respektive 50 ms. Ett högt medelvärde döljer alltså inte en lång laddningsbildruta.
Kör `Universe.App.exe --galaxy-journey-report` för ett automatiskt OpenGL-test av samma flöde. Den använder fixerade simuleringssteg och mäter faktisk CPU-/GPU-tid med GPU-synkronisering; det är en accelererad integrationstestkörning utan VSync, inte ett löfte om samma FPS under alla spelinställningar. Bildfiler för galax, nebulosa, planet och resultat hamnar i `graphics-report/`. Processen returnerar kod 2 om prestandagränserna överskrids. Enhetstester kontrollerar koordinatprecision, textur-/alpha-täthet, reproducerbarhet, hierarkiskt medlemskap, markhöjd, hela resan och att injicerade bildrutetoppar ger FAIL.
## Svarta hål ## 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. 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. Tryck **E** för en nio sekunders kameraresa till en sned vy över skivan. Sökningen jämför dubbelprecisionsavstånd. I galaxläget söker den bland stjärnkandidater inom åtta stjärngridblock (normalt omkring 32 ljusår); utan galaxlagret expanderar den upp till åtta solsystemsceller. 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. `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.

View File

@@ -2,6 +2,25 @@
"universeSeed": "0x554E495645525345", "universeSeed": "0x554E495645525345",
"cellSizeAu": 100, "cellSizeAu": 100,
"knownSolarSystemsFile": "KnownSolarSystem/solar-systems.json", "knownSolarSystemsFile": "KnownSolarSystem/solar-systems.json",
"starMotionBlur": {
"enabled": true,
"shutterFraction": 0.9,
"maximumTrailPixels": 180,
"minimumWidthPixels": 1.4,
"movingBrightness": 1.8,
"historyResetSeconds": 0.25,
"starFadeSeconds": 0.3
},
"galaxies": {
"knownGalaxiesFile": "KnownGalaxies.json",
"regionSizeLightYears": 1000000,
"systemSpacingLightYears": 4,
"galaxyStarBudget": 6000,
"localStarBudget": 2600,
"volumeSteps": 40,
"minimumBenchmarkFps": 45,
"maximumBenchmarkFrameMilliseconds": 50
},
"blackHoles": { "blackHoles": {
"occurrenceChance": 0.05, "occurrenceChance": 0.05,
"minimumMassSolar": 200000, "minimumMassSolar": 200000,

View File

@@ -13,6 +13,8 @@ internal sealed class BenchmarkOverlay : IDisposable
private long _lastRevision = -1; private long _lastRevision = -1;
private int _lastTextTick = -1; private int _lastTextTick = -1;
private string _lastPhase = string.Empty; private string _lastPhase = string.Empty;
private IBenchmarkSession? _session;
private (int Width, int Height) _size;
public BenchmarkOverlay(GL gl) public BenchmarkOverlay(GL gl)
{ {
@@ -21,8 +23,10 @@ internal sealed class BenchmarkOverlay : IDisposable
_curve = new(gl, 6, [], dynamic: true); _curve = new(gl, 6, [], dynamic: true);
} }
public void Draw(int width, int height, BenchmarkRunner benchmark) public void Draw(int width, int height, IBenchmarkSession benchmark)
{ {
if (_session != benchmark || _size != (width, height))
{ _session = benchmark; _size = (width, height); _lastRevision = -1; _lastTextTick = -1; _lastPhase = string.Empty; }
var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.2); var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.2);
var x = width - 402 * scale; var x = width - 402 * scale;
var y = 14 * scale; var y = 14 * scale;
@@ -53,13 +57,16 @@ internal sealed class BenchmarkOverlay : IDisposable
_curve.Draw(GLEnum.LineStrip); _curve.Draw(GLEnum.LineStrip);
} }
private void UpdatePanel(int width, int height, BenchmarkRunner benchmark, double x, double y, private void UpdatePanel(int width, int height, IBenchmarkSession benchmark, double x, double y,
double panelWidth, double panelHeight, double scale) double panelWidth, double panelHeight, double scale)
{ {
var vertices = new List<float>(20_000); var vertices = new List<float>(20_000);
Rect(x, y, panelWidth, panelHeight, .018f, .028f, .05f); Rect(x, y, panelWidth, panelHeight, .018f, .028f, .05f);
Rect(x, y, 3 * scale, panelHeight, .13f, .55f, .8f); Rect(x, y, 3 * scale, panelHeight, .13f, .55f, .8f);
Text(benchmark.IsRunning ? "BENCHMARK LIVE" : benchmark.Phase == "STOPPED" ? "BENCHMARK STOPPED" : "BENCHMARK COMPLETE", var title=benchmark is GalaxyJourneyBenchmark journey
? journey.IsComplete ? journey.PerformancePassed ? "GALAXY JOURNEY PASS" : "GALAXY JOURNEY FAIL" : journey.IsRunning ? "GALAXY JOURNEY LIVE" : "GALAXY JOURNEY STOPPED"
: benchmark.IsRunning ? "BENCHMARK LIVE" : benchmark.Phase == "STOPPED" ? "BENCHMARK STOPPED" : "BENCHMARK COMPLETE";
Text(title,
x + 16 * scale, y + 11 * scale, 1.75 * scale, .76f, .91f, 1f); x + 16 * scale, y + 11 * scale, 1.75 * scale, .76f, .91f, 1f);
Text($"PHASE {benchmark.Phase}", x + 16 * scale, y + 29 * scale, 1.32 * scale, .52f, .7f, .82f); Text($"PHASE {benchmark.Phase}", x + 16 * scale, y + 29 * scale, 1.32 * scale, .52f, .7f, .82f);
Text($"TARGET {benchmark.TargetLabel}", x + 16 * scale, y + 47 * scale, Text($"TARGET {benchmark.TargetLabel}", x + 16 * scale, y + 47 * scale,
@@ -74,6 +81,11 @@ internal sealed class BenchmarkOverlay : IDisposable
1.20 * scale, .7f, .83f, .91f); 1.20 * scale, .7f, .83f, .91f);
Text($"MIN {Number(benchmark.MinimumFps)} 1 PCT LOW {Number(benchmark.OnePercentLowFps)}", Text($"MIN {Number(benchmark.MinimumFps)} 1 PCT LOW {Number(benchmark.OnePercentLowFps)}",
x + 16 * scale, y + 132 * scale, 1.02 * scale, .7f, .83f, .91f); x + 16 * scale, y + 132 * scale, 1.02 * scale, .7f, .83f, .91f);
var maximum=benchmark.GraphSamples.Count==0?60:System.Math.Max(60,System.Math.Ceiling(benchmark.GraphSamples.Max()/30)*30);
Text($"{maximum:0} FPS",x+21*scale,y+154*scale,.95*scale,.4f,.6f,.7f);
Text("0 S",x+17*scale,y+219*scale,.95*scale,.4f,.6f,.7f);
var end=$"{benchmark.ElapsedSeconds:0} S";
Text(end,x+panelWidth-(end.Length*6*.95+17)*scale,y+219*scale,.95*scale,.4f,.6f,.7f);
_panel.ReplaceVertices(vertices.ToArray()); _panel.ReplaceVertices(vertices.ToArray());
@@ -108,7 +120,6 @@ internal sealed class BenchmarkOverlay : IDisposable
var grid = new List<float>(1800); var grid = new List<float>(1800);
var curve = new List<float>(samples.Count * 6); var curve = new List<float>(samples.Count * 6);
var maximum = samples.Count == 0 ? 60 : System.Math.Max(60, System.Math.Ceiling(samples.Max() / 30) * 30); var maximum = samples.Count == 0 ? 60 : System.Math.Max(60, System.Math.Ceiling(samples.Max() / 30) * 30);
maximum = System.Math.Min(maximum, 240);
for (var i = 0; i <= 4; i++) for (var i = 0; i <= 4; i++)
{ {
var py = y + graphHeight * i / 4; var py = y + graphHeight * i / 4;

View File

@@ -0,0 +1,187 @@
using System.Numerics;
using Silk.NET.OpenGL;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Graphics;
internal sealed class CosmicRenderer : IDisposable
{
private sealed class Cloud(UniversePosition center, double scale, PlanetOrientation orientation)
{
public UniversePosition Center = center;
public double Scale = scale;
public PlanetOrientation Orientation = orientation;
public Task<float[]>? Pending;
public GpuMesh? Mesh;
public int LastFrame;
}
private readonly GL _gl;
private readonly GalaxyUniverse _universe;
private readonly StarTrailRenderer _trails;
private readonly ShaderProgram _volume, _stars;
private readonly GpuMesh _fullscreen;
private readonly GpuMesh _markers;
private readonly Dictionary<string, Cloud> _clouds = [];
private readonly Dictionary<string, uint> _textures = [];
private readonly uint _white;
private int _frame;
private int _uploads;
private Cloud? _localCurrent, _localPrevious;
private double _localBlend = 1;
private string? _localGalaxyId;
private long _localCutVersion = -1;
public int StarCount { get; private set; }
public CosmicRenderer(GL gl, GalaxyUniverse universe, StarTrailRenderer trails)
{
_gl = gl; _universe = universe; _trails = trails;
_volume = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"), ShaderSources.Load("galaxy.frag"));
_stars = ShaderProgram.Create(gl, ShaderSources.Load("cosmic.vert"), ShaderSources.Load("cosmic.frag"));
_fullscreen = new(gl, 3, [-1,-1,0,3,-1,0,-1,3,0]);
_markers = new(gl,7,[],dynamic:true);
_white = Texture(new RasterImage(2,2,Enumerable.Repeat((byte)255,16).ToArray()));
}
public void Draw(FlyCamera camera, int width, int height, double frameSeconds)
{
_frame++; _uploads = 0; StarCount = 0;
_gl.Disable(GLEnum.DepthTest); _gl.DepthMask(false); _gl.Enable(GLEnum.Blend); _gl.BlendFunc(GLEnum.One, GLEnum.One);
var galaxies = _universe.VisibleGalaxies(camera.Position).OrderBy(g => camera.Position.RelativeTo(g.Center, camera.CellSizeMeters).LengthSquared).ToArray();
foreach (var galaxy in galaxies.Take(10))
{
DrawVolume(camera, width, height, galaxy.Id, galaxy.Center, galaxy.RadiusMeters, galaxy.ThicknessMeters / galaxy.RadiusMeters,
galaxy.Orientation, galaxy.SpiralArms, galaxy.DensityGamma, galaxy.Seed, galaxy.Texture, 0);
if(camera.Position.RelativeTo(galaxy.Center,camera.CellSizeMeters).Length < galaxy.RadiusMeters*6)
{
var cloud = GetCloud(galaxy.Id, galaxy.Center, galaxy.RadiusMeters, galaxy.Orientation,
() => BuildStars(galaxy, null, _universe.Settings.GalaxyStarBudget));
DrawCloud(camera, width, height, cloud, _universe.SpacingCells * _universe.CellSize * 24 / galaxy.RadiusMeters);
}
}
var current = _universe.GalaxyAt(camera.Position);
if (_localGalaxyId != current.Id || _localCutVersion != camera.MotionHistoryVersion)
{
_localCurrent = null; _localPrevious = null; _localBlend = 1;
_localGalaxyId = current.Id; _localCutVersion = camera.MotionHistoryVersion;
}
var markerScale=_universe.Settings.RegionSizeLightYears*UniverseScale.LightYearMeters;
var markers=new List<float>(256);
void Marker(UniversePosition position,double radius,Vector3 color,bool forcePoint=false)
{
var relative=position.RelativeTo(camera.Position,camera.CellSizeMeters);
var angular=radius/System.Math.Max(radius,relative.Length);
var t=System.Math.Clamp((angular-.004)/.021,0,1);var fade=forcePoint?1:1-t*t*(3-2*t);
if(fade<.01)return;
var p=relative/markerScale;
markers.AddRange([(float)p.X,(float)p.Y,(float)p.Z,color.X*(float)fade,color.Y*(float)fade,color.Z*(float)fade,2.5f]);
}
var volumeIds=galaxies.Take(10).Select(g=>g.Id).ToHashSet();
foreach(var galaxy in galaxies)Marker(galaxy.Center,galaxy.RadiusMeters,new(.3f,.3f,.5f),!volumeIds.Contains(galaxy.Id));
var nearNebulae=current.Nebulae.OrderBy(n=>camera.Position.RelativeTo(n.Center,camera.CellSizeMeters).LengthSquared).Take(5).Select(n=>n.Id).ToHashSet();
foreach(var nebula in current.Nebulae)Marker(nebula.Center,nebula.RadiusMeters,new(.2f,.12f,.26f),!nearNebulae.Contains(nebula.Id));
_markers.ReplaceVertices(markers.ToArray());
Camera(_stars,camera,width,height,camera.Position,markerScale,PlanetOrientation.Identity);
_stars.Set("uNearFade",1e-10f);_markers.Draw(GLEnum.Points);
if (camera.Position.RelativeTo(current.Center, camera.CellSizeMeters).Length < current.RadiusMeters * 1.8)
{
var at = _universe.StellarBlock(camera.Position.Sector);
// A stable sampling anchor spans 32 stellar blocks. Crossing every
// individual system grid boundary must not replace the whole sky.
var block = new SectorCoordinate(GalaxyUniverse.FloorDiv(at.X,32)*32,
GalaxyUniverse.FloorDiv(at.Y,32)*32,GalaxyUniverse.FloorDiv(at.Z,32)*32);
var origin = UniversePosition.AtCellCenter(new(block.X * _universe.SpacingCells, block.Y * _universe.SpacingCells, block.Z * _universe.SpacingCells), camera.CellSizeMeters);
var scale = _universe.SpacingCells * _universe.CellSize * 64;
var cloud = GetCloud($"local:{block.X}:{block.Y}:{block.Z}", origin, scale, PlanetOrientation.Identity,
() => BuildStars(current, (origin, scale, block), _universe.Settings.LocalStarBudget));
if (cloud.Mesh is not null && cloud != _localCurrent)
{
_localPrevious = _localCurrent; _localCurrent = cloud;
_localBlend = 0;
}
_localBlend = System.Math.Min(1,_localBlend+System.Math.Clamp(frameSeconds,0,.1)/_trails.Settings.StarFadeSeconds);
var fadeDistance = _universe.SpacingCells * _universe.CellSize * .15 / scale;
if (_localPrevious is not null && _localBlend < 1) DrawCloud(camera,width,height,_localPrevious,fadeDistance,(float)(1-_localBlend));
if (_localCurrent is not null) DrawCloud(camera,width,height,_localCurrent,fadeDistance,(float)_localBlend);
if (_localBlend >= 1) _localPrevious = null;
foreach (var n in current.Nebulae.OrderBy(n => camera.Position.RelativeTo(n.Center, camera.CellSizeMeters).LengthSquared).Take(5))
DrawVolume(camera, width, height, n.Id, n.Center, n.RadiusMeters, 1, n.Orientation, 0, 1, n.Seed, n.Texture, 1);
}
if (_clouds.Count > 48)
foreach (var key in _clouds.Where(p => p.Value != _localCurrent && p.Value != _localPrevious && p.Value.LastFrame < _frame - 2 && p.Value.Pending?.IsCompleted != false).Select(p => p.Key).Take(_clouds.Count - 48).ToArray())
{ _clouds[key].Mesh?.Dispose(); _clouds.Remove(key); }
_gl.Disable(GLEnum.Blend); _gl.Enable(GLEnum.DepthTest);
}
private Cloud GetCloud(string key, UniversePosition center, double scale, PlanetOrientation orientation, Func<float[]> build)
{
if (!_clouds.TryGetValue(key, out var cloud))
{ cloud = new(center, scale, orientation); _clouds.Add(key, cloud); }
cloud.LastFrame = _frame;
if (cloud.Pending is null && cloud.Mesh is null && _clouds.Values.Count(c => c.Pending?.IsCompleted == false) < 2)
cloud.Pending = Task.Run(build);
if (cloud.Pending is { IsCompletedSuccessfully: true } ready && _uploads < 1)
{ cloud.Mesh = new(_gl, 7, ready.Result); cloud.Pending = null; _uploads++; }
return cloud;
}
private float[] BuildStars(GalaxyData galaxy, (UniversePosition Center, double Scale, SectorCoordinate Block)? local, int budget)
{
var random = new DeterministicRandom(galaxy.Seed ^ (local.HasValue ? GalaxyUniverse.Hash(local.Value.Block, 55) : 77));
var vertices = new List<float>(budget * 7); var used = new HashSet<SectorCoordinate>();
for (var i = 0; i < budget * 24 && used.Count < budget; i++)
{
SectorCoordinate block;
if (local is { } l) block = new(l.Block.X + random.NextInt(-64,65), l.Block.Y + random.NextInt(-64,65), l.Block.Z + random.NextInt(-64,65));
else
{
var p = new DVector3(random.NextDouble()*2-1,(random.NextDouble()*6-3)*galaxy.ThicknessMeters/galaxy.RadiusMeters,random.NextDouble()*2-1) * galaxy.RadiusMeters;
block = _universe.StellarBlock(galaxy.Center.Add(galaxy.Orientation.ToWorld(p), _universe.CellSize).Sector);
}
if (!_universe.Candidate(galaxy, block, out var sector) || !used.Add(sector)) continue;
var position = UniversePosition.AtCellCenter(sector, _universe.CellSize);
var delta = local is { } ll ? position.RelativeTo(ll.Center, _universe.CellSize) / ll.Scale
: galaxy.Orientation.ToBody(position.RelativeTo(galaxy.Center, _universe.CellSize)) / galaxy.RadiusMeters;
var tint = random.NextDouble(); var brightness = .14 + random.NextDouble() * .9;
vertices.AddRange([(float)delta.X,(float)delta.Y,(float)delta.Z,(float)(brightness*(.7+tint*.3)),(float)(brightness*.82),(float)(brightness*(1.0-tint*.4)),(float)(1.2+random.NextDouble()*2.3)]);
}
return vertices.ToArray();
}
private void Camera(ShaderProgram shader, FlyCamera camera, int width, int height, UniversePosition center, double scale, PlanetOrientation orientation)
{
shader.Use(); shader.Set("uOrigin", (camera.Position.RelativeTo(center, camera.CellSizeMeters)/scale).ToVector3());
shader.Set("uAxisX", orientation.ToWorld(new(1,0,0)).ToVector3()); shader.Set("uAxisY", orientation.ToWorld(new(0,1,0)).ToVector3()); shader.Set("uAxisZ", orientation.ToWorld(new(0,0,1)).ToVector3());
shader.Set("uForward",camera.Forward.ToVector3()); shader.Set("uRight",camera.Right.ToVector3()); shader.Set("uUp",camera.Up.ToVector3());
shader.Set("uAspect",(float)width/height); shader.Set("uTanHalfFov",(float)System.Math.Tan(camera.FieldOfViewRadians*.5));
}
private void DrawCloud(FlyCamera camera,int width,int height,Cloud cloud,double nearFade,float visibility=1)
{
if (cloud.Mesh is null) return;
cloud.LastFrame = _frame;
_trails.Draw(cloud.Mesh,cloud.Center,cloud.Scale,cloud.Orientation,nearFade,visibility);
}
private void DrawVolume(FlyCamera camera,int width,int height,string id,UniversePosition center,double radius,double thickness,
PlanetOrientation orientation,int arms,double gamma,ulong seed,RasterImage? texture,int kind)
{
var relative = center.RelativeTo(camera.Position,camera.CellSizeMeters);
if (DVector3.Dot(relative,camera.Forward)<-radius*1.2 || radius / System.Math.Max(radius,relative.Length) < .0003) return;
var angular=radius/System.Math.Max(radius,relative.Length);
var opacity=System.Math.Clamp((angular-.004)/.021,0,1);opacity=opacity*opacity*(3-2*opacity);
if(opacity<=0)return;
Camera(_volume,camera,width,height,center,radius,orientation);
_volume.Set("uOpacity",(float)opacity);
_volume.Set("uThickness",(float)thickness); _volume.Set("uArms",(float)arms); _volume.Set("uGamma",(float)gamma);
_volume.Set("uSeed",(float)(seed&65535)*.001f); _volume.Set("uKind",kind); _volume.Set("uSteps",_universe.Settings.VolumeSteps);
_volume.Set("uHasTexture",texture is null?0:1); _volume.Set("uDensity",0);
_gl.ActiveTexture(GLEnum.Texture0);
if (texture is not null && !_textures.ContainsKey(id)) _textures[id]=Texture(texture);
_gl.BindTexture(GLEnum.Texture2D,texture is null?_white:_textures[id]); _fullscreen.Draw();
}
private unsafe uint Texture(RasterImage image)
{
var handle=_gl.GenTexture();_gl.BindTexture(GLEnum.Texture2D,handle);
fixed(byte* pixels=image.Rgba) _gl.TexImage2D(GLEnum.Texture2D,0,InternalFormat.Rgba8,(uint)image.Width,(uint)image.Height,0,PixelFormat.Rgba,PixelType.UnsignedByte,pixels);
_gl.TexParameter(GLEnum.Texture2D,GLEnum.TextureMinFilter,(int)GLEnum.Linear);_gl.TexParameter(GLEnum.Texture2D,GLEnum.TextureMagFilter,(int)GLEnum.Linear);
_gl.TexParameter(GLEnum.Texture2D,GLEnum.TextureWrapS,(int)GLEnum.ClampToEdge);_gl.TexParameter(GLEnum.Texture2D,GLEnum.TextureWrapT,(int)GLEnum.ClampToEdge);
return handle;
}
public void Dispose(){foreach(var cloud in _clouds.Values)cloud.Mesh?.Dispose();foreach(var texture in _textures.Values)_gl.DeleteTexture(texture);_gl.DeleteTexture(_white);_volume.Dispose();_stars.Dispose();_fullscreen.Dispose();_markers.Dispose();}
}

View File

@@ -13,6 +13,100 @@ 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 RunStarTrails(GL gl,StarMotionBlurSettings settings,int width,int height)
{
using var trails=new StarTrailRenderer(gl,settings with {Enabled=true});
using var target=new RenderTarget(gl,width,height);
var cell=UniverseScale.DefaultCellSizeMeters;const double scale=1e12;
var center=UniversePosition.AtCellCenter(new(600_000_000_000_000_000,0,0),cell);
var camera=new FlyCamera(center,cell);
var random=new DeterministicRandom(0x5354415254524149UL);var data=new List<float>();
for(var i=0;i<900;i++)
{
var z=8+random.NextDouble()*52;
data.AddRange([(float)((random.NextDouble()*2-1)*z*.7),(float)((random.NextDouble()*2-1)*z*.43),-(float)z,
1.1f,(float)(.75+random.NextDouble()*.35),.8f,(float)(1.5+random.NextDouble()*1.5)]);
}
using var stars=new GpuMesh(gl,7,data.ToArray());
float[] Frame(GpuMesh mesh)
{
target.Bind();gl.Disable(GLEnum.DepthTest);gl.DepthMask(false);
gl.ClearColor(0,0,0,1);gl.Clear(ClearBufferMask.ColorBufferBit);
gl.Enable(GLEnum.Blend);gl.BlendFunc(GLEnum.One,GLEnum.One);
trails.BeginFrame(camera,width,height,1d/60);
trails.Draw(mesh,center,scale,PlanetOrientation.Identity);
gl.Finish();return Read(gl,width,height);
}
Frame(stars);
camera.SetPlayerPose(center.Add(new(0,0,-12*scale),cell),camera.Forward,camera.Up);
var moving=Frame(stars);
var stopped=Frame(stars);
int Coverage(float[] pixels)=>Enumerable.Range(0,width*height).Count(i=>pixels[i*4]>.035);
var movingCoverage=Coverage(moving);var stillCoverage=Coverage(stopped);
if(moving.Any(p=>!float.IsFinite(p))||movingCoverage<stillCoverage*1.5)
throw new InvalidOperationException($"Star trails did not stretch the moving stars: moving {movingCoverage}, stopped {stillCoverage} pixels.");
trails.ReloadShaders();
camera.SetPlayerPose(camera.Position,camera.Forward,camera.Up,resetMotionHistory:true);
var cut=Frame(stars);
if(trails.History.Valid||cut.Zip(stopped,(a,b)=>System.Math.Abs(a-b)).Max()>.0001)
throw new InvalidOperationException("Star trails retained an afterimage after stopping or a camera cut.");
using var single=new GpuMesh(gl,7,[0,0,-10,1,1,1,2]);
camera.SetPlayerPose(center,camera.Forward,camera.Up,resetMotionHistory:true);Frame(single);
camera.SetPlayerPose(center.Add(new(-6*scale,0,0),cell),camera.Forward,camera.Up);
var bounded=Frame(single);
var columns=Enumerable.Range(0,width).Where(x=>Enumerable.Range(0,height).Any(y=>bounded[(y*width+x)*4]>.035)).ToArray();
var span=columns.Length==0?0:columns[^1]-columns[0]+1;
if(span<settings.MaximumTrailPixels*.5||span>settings.MaximumTrailPixels+14)
throw new InvalidOperationException($"Star trail pixel limit failed: span {span}, limit {settings.MaximumTrailPixels}.");
if(gl.GetError() is var error&&error!=GLEnum.NoError)throw new InvalidOperationException($"Star trails GL error: {error}");
var directory=Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(directory);
WritePng(Path.Combine(directory,"star-motion-blur.png"),width,height,moving);
WritePng(Path.Combine(directory,"star-motion-stopped.png"),width,height,stopped);
Console.WriteLine($"Star trails passed: {movingCoverage} moving vs {stillCoverage} stationary pixels; capped trail {span} px; camera cut/stop clean; OpenGL {gl.GetStringS(GLEnum.Renderer)}.");
gl.BindFramebuffer(GLEnum.Framebuffer,0);gl.Disable(GLEnum.Blend);gl.Enable(GLEnum.DepthTest);gl.DepthMask(true);
}
public static void RunGalaxyJourney(GL gl, UniverseRenderer renderer, FlyCamera camera, UniverseStreamer streamer,
GalaxyJourneyBenchmark journey, int width, int height)
{
var neighborhood = streamer.Load(camera.Position.Sector);
journey.RenderEnvironment = new { Width = width, Height = height, Gpu = renderer.HardwareName, Mode = "synchronized-accelerated", SimulationStepSeconds = 1d/12, FieldOfViewDegrees = camera.FieldOfViewRadians * 180 / System.Math.PI };
var startPosition=camera.Position;var startForward=camera.Forward;var startUp=camera.Up;
var nebula=journey.HomeGalaxy.Nebulae[0];
var radial=nebula.Orientation.ToWorld(new DVector3(.1,.05,1).Normalized());
camera.SetPlayerPose(nebula.Center.Add(radial*nebula.RadiusMeters*2.8,camera.CellSizeMeters),-radial,nebula.Orientation.ToWorld(new(0,1,0)),resetMotionHistory:true);
for(var i=0;i<6;i++){renderer.Render(camera,streamer.Load(camera.Position.Sector),1d/60);gl.Finish();}
var nebulaDirectory=Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(nebulaDirectory);
WritePng(Path.Combine(nebulaDirectory,"galaxy-nebula.png"),width,height,Read(gl,width,height),false);
camera.SetPlayerPose(startPosition,startForward,startUp,resetMotionHistory:true);
neighborhood=streamer.Load(camera.Position.Sector);
for (var i=0;i<8;i++) {renderer.Render(camera,neighborhood,1d/60);gl.Finish();}
var directory = Path.Combine(AppContext.BaseDirectory,"graphics-report");Directory.CreateDirectory(directory);
var captured = new HashSet<string>();
var timer = System.Diagnostics.Stopwatch.StartNew();
while(journey.IsRunning)
{
journey.Update(1d/12,camera);
neighborhood=streamer.Load(camera.Position.Sector);
renderer.Render(camera,neighborhood,1d/12);renderer.DrawBenchmarkOverlay(journey);gl.Finish();
var seconds=timer.Elapsed.TotalSeconds;timer.Restart();
journey.RecordFrame(seconds);
if(captured.Add(journey.Phase))Console.WriteLine($"Galaxy journey: {journey.Phase}; sector {camera.Position.Sector}; galaxy {neighborhood.ActiveSystem.GalaxyId}");
// Capture late in each phase, after the point-cloud workers have had time to publish.
if(journey.ElapsedSeconds is >23.8 and <24 || journey.ElapsedSeconds is >44.8 and <45 || journey.ElapsedSeconds is >53.8 and <54 || journey.ElapsedSeconds is >89.8 and <90)
{
renderer.DrawBenchmarkOverlay(journey);
WritePng(Path.Combine(directory,$"galaxy-journey-{(int)journey.ElapsedSeconds}.png"),width,height,Read(gl,width,height),false);
timer.Restart();
}
}
renderer.DrawBenchmarkOverlay(journey);
WritePng(Path.Combine(directory,"galaxy-journey-results.png"),width,height,Read(gl,width,height),false);
if(gl.GetError() is var error && error!=GLEnum.NoError)throw new InvalidOperationException($"Galaxy journey GL error: {error}");
var report=journey.SaveReport();
Console.WriteLine($"Galaxy journey completed; target {journey.TargetGalaxy.Id} / {journey.TargetPlanet.Id}; average {journey.AverageFps:F1} FPS; 1% low {journey.OnePercentLowFps:F1} FPS; performance passed {journey.PerformancePassed}; report {report}");
if(!journey.PerformancePassed)Environment.ExitCode=2;
}
public static void RunBlackHole(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, StarData hole, public static void RunBlackHole(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, StarData hole,
double cellSize, int width, int height) double cellSize, int width, int height)
{ {
@@ -74,12 +168,7 @@ internal static class GraphicsReport
var dir = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(dir); var dir = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(dir);
WritePng(Path.Combine(dir, "pause-menu.png"), width, height, pixels, false); WritePng(Path.Combine(dir, "pause-menu.png"), width, height, pixels, false);
var scale = System.Math.Min(width / 1440d, height / 900d); var scale = System.Math.Min(width / 1440d, height / 900d);
if (renderer.MenuHit(width * .5, height * .5 - 152 * scale, width, height) != 0 if (Enumerable.Range(0,7).Any(i => renderer.MenuHit(width*.5,height*.5+(-182+i*57)*scale,width,height)!=i)
|| renderer.MenuHit(width * .5, height * .5 - 95 * scale, width, height) != 1
|| renderer.MenuHit(width * .5, height * .5 - 38 * scale, width, height) != 2
|| renderer.MenuHit(width * .5, height * .5 + 19 * scale, width, height) != 3
|| renderer.MenuHit(width * .5, height * .5 + 76 * scale, width, height) != 4
|| renderer.MenuHit(width * .5, height * .5 + 133 * scale, width, height) != 5
|| renderer.MenuHit(0, 0, width, height) != -1) || renderer.MenuHit(0, 0, width, height) != -1)
throw new InvalidOperationException("Menu hit testing failed."); throw new InvalidOperationException("Menu hit testing failed.");
Console.WriteLine($"Pause menu rendered and hit testing passed. Image: {dir}"); Console.WriteLine($"Pause menu rendered and hit testing passed. Image: {dir}");

View File

@@ -8,7 +8,7 @@ internal sealed class PauseMenu : IDisposable
private readonly GpuMesh _mesh; private readonly GpuMesh _mesh;
public int Selected { get; set; } public int Selected { get; set; }
public PauseMenu(GL gl) => _mesh = new(gl, 6, [], dynamic: true); public PauseMenu(GL gl) => _mesh = new(gl, 6, [], dynamic: true);
private static readonly string[] Labels = ["PLAY", "FREE CAMERA", "DEBUG OFF", "BENCHMARK EARTH", "BENCHMARK UNKNOWN", "QUIT"]; private static readonly string[] Labels = ["PLAY", "FREE CAMERA", "DEBUG OFF", "BENCHMARK EARTH", "BENCHMARK UNKNOWN", "GALAXY JOURNEY", "QUIT"];
internal static readonly Dictionary<char, string[]> Font = new() internal static readonly Dictionary<char, string[]> Font = new()
{ {
['A']=["01110","10001","10001","11111","10001","10001","10001"], ['C']=["01111","10000","10000","10000","10000","10000","01111"], ['A']=["01110","10001","10001","11111","10001","10001","10001"], ['C']=["01111","10000","10000","10000","10000","10000","01111"],
@@ -18,6 +18,7 @@ internal sealed class PauseMenu : IDisposable
['H']=["10001","10001","10001","11111","10001","10001","10001"], ['N']=["10001","11001","10101","10011","10001","10001","10001"], ['H']=["10001","10001","10001","11111","10001","10001","10001"], ['N']=["10001","11001","10101","10011","10001","10001","10001"],
['V']=["10001","10001","10001","10001","10001","01010","00100"], ['W']=["10001","10001","10001","10101","10101","10101","01010"], ['V']=["10001","10001","10001","10001","10001","01010","00100"], ['W']=["10001","10001","10001","10101","10101","10101","01010"],
['K']=["10001","10010","10100","11000","10100","10010","10001"], ['K']=["10001","10010","10100","11000","10100","10010","10001"],
['J']=["00111","00010","00010","00010","10010","10010","01100"], ['X']=["10001","10001","01010","00100","01010","10001","10001"],
['L']=["10000","10000","10000","10000","10000","10000","11111"], ['M']=["10001","11011","10101","10101","10001","10001","10001"], ['L']=["10000","10000","10000","10000","10000","10000","11111"], ['M']=["10001","11011","10101","10101","10001","10001","10001"],
['O']=["01110","10001","10001","10001","10001","10001","01110"], ['P']=["11110","10001","10001","11110","10000","10000","10000"], ['O']=["01110","10001","10001","10001","10001","10001","01110"], ['P']=["11110","10001","10001","11110","10000","10000","10000"],
['Q']=["01110","10001","10001","10001","10101","10010","01101"], ['R']=["11110","10001","10001","11110","10100","10010","10001"], ['Q']=["01110","10001","10001","10001","10101","10010","01101"], ['R']=["11110","10001","10001","11110","10100","10010","10001"],
@@ -44,7 +45,7 @@ internal sealed class PauseMenu : IDisposable
var (s, cx, cy) = Layout(width, height); var (s, cx, cy) = Layout(width, height);
if (s <= 0 || x < cx - 190 * s || x > cx + 190 * s) return -1; if (s <= 0 || x < cx - 190 * s || x > cx + 190 * s) return -1;
for (var i = 0; i < Labels.Length; i++) for (var i = 0; i < Labels.Length; i++)
if (y >= cy + (-174 + i * 57) * s && y <= cy + (-130 + i * 57) * s) return i; if (y >= cy + (-204 + i * 57) * s && y <= cy + (-160 + i * 57) * s) return i;
return -1; return -1;
} }
@@ -70,19 +71,19 @@ internal sealed class PauseMenu : IDisposable
x += 6 * unit; x += 6 * unit;
} }
} }
Rect(cx - 230*s, cy - 260*s, 460*s, 520*s, .025f, .04f, .075f); Rect(cx - 230*s, cy - 290*s, 460*s, 580*s, .025f, .04f, .075f);
Rect(cx - 230*s, cy - 260*s, 460*s, 4*s, .25f, .65f, .9f); Rect(cx - 230*s, cy - 290*s, 460*s, 4*s, .25f, .65f, .9f);
Text("PAUSED", cx, cy - 226*s, 5*s, .9f, .95f, 1); Text("PAUSED", cx, cy - 256*s, 5*s, .9f, .95f, 1);
for (var i = 0; i < Labels.Length; i++) for (var i = 0; i < Labels.Length; i++)
{ {
var selected = i == Selected; var selected = i == Selected;
var y = cy + (-174 + i * 57) * s; var y = cy + (-204 + i * 57) * s;
Rect(cx - 190*s, y, 380*s, 44*s, selected ? .12f : .055f, selected ? .25f : .10f, selected ? .36f : .16f); Rect(cx - 190*s, y, 380*s, 44*s, selected ? .12f : .055f, selected ? .25f : .10f, selected ? .36f : .16f);
if (selected) Rect(cx - 190*s, y, 4*s, 44*s, .35f, .8f, 1); if (selected) Rect(cx - 190*s, y, 4*s, 44*s, .35f, .8f, 1);
var label = i == 2 ? debugEnabled ? "DEBUG ON" : "DEBUG OFF" : Labels[i]; var label = i == 2 ? debugEnabled ? "DEBUG ON" : "DEBUG OFF" : Labels[i];
Text(label, cx, y + 12*s, (i >= 3 ? 3.35 : 4) * s, .92f, .95f, 1); Text(label, cx, y + 12*s, (i >= 3 ? 3.35 : 4) * s, .92f, .95f, 1);
} }
Text("P OR ESC TO RESUME", cx, cy + 220*s, 2*s, .6f, .7f, .8f); Text("P OR ESC TO RESUME", cx, cy + 250*s, 2*s, .6f, .7f, .8f);
_mesh.ReplaceVertices(data.ToArray()); _mesh.Draw(GLEnum.Triangles); _mesh.ReplaceVertices(data.ToArray()); _mesh.Draw(GLEnum.Triangles);
} }
public void Dispose() => _mesh.Dispose(); public void Dispose() => _mesh.Dispose();

View File

@@ -16,6 +16,7 @@ internal sealed class PerformanceOverlay : IDisposable
var rows = new List<(string Label, string Value, bool Emphasis)>(); var rows = new List<(string Label, string Value, bool Emphasis)>();
AddMetric("GPU FRAME", "GPU FRAME", true); AddMetric("GPU FRAME", "GPU FRAME", true);
AddMetric("SKY / STARFIELD", "SKY / STARFIELD"); AddMetric("SKY / STARFIELD", "SKY / STARFIELD");
AddMetric("GALAXIES / NEBULAE", "GALAXIES / NEBULAE");
AddMetric("PLANETS TOTAL", "PLANETS TOTAL"); AddMetric("PLANETS TOTAL", "PLANETS TOTAL");
foreach (var key in values.Keys.Where(k => k.StartsWith("PLANET ", StringComparison.Ordinal) && k != "PLANETS TOTAL" && k != "GPU FRAME") foreach (var key in values.Keys.Where(k => k.StartsWith("PLANET ", StringComparison.Ordinal) && k != "PLANETS TOTAL" && k != "GPU FRAME")
.OrderBy(k => k, StringComparer.Ordinal)) .OrderBy(k => k, StringComparer.Ordinal))

View File

@@ -17,24 +17,29 @@ internal sealed unsafe class ShaderProgram : IDisposable
public uint Handle { get; } public uint Handle { get; }
public static ShaderProgram Create(GL gl, string vertexSource, string fragmentSource) public static ShaderProgram Create(GL gl, string vertexSource, string fragmentSource, string? geometrySource = null)
{ {
var vertex = Compile(gl, ShaderType.VertexShader, vertexSource); uint vertex = 0, fragment = 0, geometry = 0, program = 0;
var fragment = Compile(gl, ShaderType.FragmentShader, fragmentSource); try
var program = gl.CreateProgram();
gl.AttachShader(program, vertex);
gl.AttachShader(program, fragment);
gl.LinkProgram(program);
gl.GetProgram(program, GLEnum.LinkStatus, out var linked);
gl.DeleteShader(vertex);
gl.DeleteShader(fragment);
if (linked == 0)
{ {
var log = gl.GetProgramInfoLog(program); vertex = Compile(gl, ShaderType.VertexShader, vertexSource);
gl.DeleteProgram(program); fragment = Compile(gl, ShaderType.FragmentShader, fragmentSource);
throw new InvalidOperationException($"OpenGL shader link failed: {log}"); if (geometrySource is not null) geometry = Compile(gl, ShaderType.GeometryShader, geometrySource);
program = gl.CreateProgram();
gl.AttachShader(program, vertex); gl.AttachShader(program, fragment);
if (geometry != 0) gl.AttachShader(program, geometry);
gl.LinkProgram(program);
gl.GetProgram(program, GLEnum.LinkStatus, out var linked);
if (linked == 0) throw new InvalidOperationException($"OpenGL shader link failed: {gl.GetProgramInfoLog(program)}");
return new(gl, program);
}
catch { if (program != 0) gl.DeleteProgram(program); throw; }
finally
{
if (vertex != 0) gl.DeleteShader(vertex);
if (fragment != 0) gl.DeleteShader(fragment);
if (geometry != 0) gl.DeleteShader(geometry);
} }
return new(gl, program);
} }
public void Use() => _gl.UseProgram(Handle); public void Use() => _gl.UseProgram(Handle);

View File

@@ -0,0 +1,4 @@
#version 330 core
in vec3 vColor;
out vec4 fragColor;
void main(){vec2 p=gl_PointCoord*2.0-1.0;float r=dot(p,p);if(r>1.0)discard;fragColor=vec4(vColor*exp(-r*3.5),0);}

View File

@@ -0,0 +1,23 @@
#version 330 core
layout(location=0) in vec3 aPosition;
layout(location=1) in vec3 aColor;
layout(location=2) in float aSize;
uniform vec3 uOrigin;
uniform vec3 uAxisX;
uniform vec3 uAxisY;
uniform vec3 uAxisZ;
uniform vec3 uForward;
uniform vec3 uRight;
uniform vec3 uUp;
uniform float uAspect;
uniform float uTanHalfFov;
uniform float uNearFade;
out vec3 vColor;
void main(){
vec3 delta=uAxisX*aPosition.x+uAxisY*aPosition.y+uAxisZ*aPosition.z-uOrigin;
float d=length(delta),z=dot(delta,uForward);
vec2 xy=vec2(dot(delta,uRight)/uAspect,dot(delta,uUp))/uTanHalfFov;
gl_Position=z>0.0?vec4(xy,z*.999999,z):vec4(2,2,2,1);
gl_PointSize=clamp(aSize,1.0,4.5);
vColor=aColor*smoothstep(uNearFade,uNearFade*2.0,d);
}

View File

@@ -0,0 +1,62 @@
#version 330 core
#include "camera-ray.glsl"
in vec2 vUv;
uniform vec3 uOrigin;
uniform vec3 uAxisX;
uniform vec3 uAxisY;
uniform vec3 uAxisZ;
uniform float uThickness;
uniform float uArms;
uniform float uGamma;
uniform float uSeed;
uniform int uKind;
uniform int uSteps;
uniform int uHasTexture;
uniform sampler2D uDensity;
uniform float uOpacity;
out vec4 fragColor;
float turbulence(vec3 p){return .5+.23*sin(p.x*17.0+sin(p.y*11.0))*sin(p.z*19.0+p.y*7.0)+.12*sin(dot(p,vec3(61,37,83)));}
vec3 local(vec3 p){return vec3(dot(p,uAxisX),dot(p,uAxisY),dot(p,uAxisZ));}
void main(){
vec3 rd=local(cameraRay(vUv)),ro=local(uOrigin);
vec3 bounds=uKind==0?vec3(1.05,max(.24,uThickness*5.0),1.05):vec3(1.1);
vec3 o=ro/bounds,d=rd/bounds;
float a=dot(d,d),b=dot(o,d),c=dot(o,o)-1.0,disc=b*b-a*c;
if(disc<0.0)discard;
float entry=max(0.0,(-b-sqrt(disc))/a),exit=(-b+sqrt(disc))/a;
if(exit<entry)discard;
float ds=(exit-entry)/float(uSteps);
vec3 emission=vec3(0);float trans=1.0;
for(int i=0;i<96;i++){
if(i>=uSteps)break;
vec3 p=ro+rd*(entry+(float(i)+.5)*ds);
float n=turbulence(p*3.0+uSeed),density=0.0;
vec3 color;
if(uKind==0){
float radius=length(p.xz);
vec2 uv=p.xz*vec2(.5,-.5)+.5;
vec4 map=texture(uDensity,clamp(uv,vec2(0),vec2(1)));
float spiral=atan(p.z,p.x)*uArms-log(radius+.05)*5.8;
float pattern=uHasTexture==1?dot(map.rgb,vec3(.2126,.7152,.0722))*map.a:
(.05+pow(.5+.5*cos(spiral),5.0)*.8)*pow(max(0.0,1.0-radius),.7)+exp(-radius*radius*65.0)*.8;
float disk=pow(clamp(pattern,0.0,1.0),uGamma)*exp(-abs(p.y)/uThickness)*(1.0-smoothstep(.94,1.0,radius));
float bulge=exp(-dot(p,p)*100.0)*2.0;
density=disk*(.55+.65*n)+bulge;
color=mix(vec3(.24,.24,.65),vec3(1.6,1.35,.95),clamp(bulge+pattern*.7,0.0,1.0));
if(uHasTexture==1)color=mix(color,map.rgb*2.1,.55);
emission+=trans*color*density*ds*1.7;
trans*=exp(-disk*(1.0-n)*ds*.7);
}else{
float shell1=exp(-pow((length((p-vec3(0,.36,0))*vec3(1,1.25,1))-.43)/.055,2.0));
float shell2=exp(-pow((length((p+vec3(0,.36,0))*vec3(1,1.25,1))-.43)/.055,2.0));
float torus=exp(-pow((length(p.xz)-.21)/.055,2.0)-p.y*p.y*240.0);
density=(shell1+shell2+torus*2.5)*(.25+n*n);
color=mix(vec3(1.2,.16,.025),vec3(.05,1.1,.68),exp(-dot(p,p)*18.0));
if(uHasTexture==1){vec4 map=texture(uDensity,clamp(p.xy*vec2(.5,-.5)+.5,vec2(0),vec2(1)));density*=.35+dot(map.rgb,vec3(.33))*map.a;color=mix(color,map.rgb*2.5,.5);}
emission+=trans*color*density*ds*.85;
trans*=exp(-density*ds*.5);
}
}
float localExposure=uKind==0&&length(ro)<1.1?.14:1.0;
fragColor=vec4(emission*uOpacity*localExposure,0);
}

View File

@@ -11,6 +11,7 @@ uniform vec3 uStarDirections[2];
uniform vec3 uStarColors[2]; uniform vec3 uStarColors[2];
uniform float uStarAngles[2]; uniform float uStarAngles[2];
uniform float uTime; uniform float uTime;
uniform float uBackgroundStrength;
out vec4 fragColor; out vec4 fragColor;
void main() { void main() {
vec2 xy=(vUv*2.0-1.0)*uTanHalfFov; vec2 xy=(vUv*2.0-1.0)*uTanHalfFov;
@@ -18,6 +19,7 @@ void main() {
float band=pow(max(0.0,1.0-abs(dot(rd,normalize(vec3(0.32,0.72,0.48))))),17.0); float band=pow(max(0.0,1.0-abs(dot(rd,normalize(vec3(0.32,0.72,0.48))))),17.0);
float dust=smoothstep(-0.26,0.36,fbm(rd*8.0+vec3(4.1,9.2,3.4))); float dust=smoothstep(-0.26,0.36,fbm(rd*8.0+vec3(4.1,9.2,3.4)));
vec3 color=vec3(0.00013,0.00020,0.00042)+vec3(0.0013,0.0016,0.0026)*band*dust; vec3 color=vec3(0.00013,0.00020,0.00042)+vec3(0.0013,0.0016,0.0026)*band*dust;
color*=uBackgroundStrength;
for(int i=0;i<2;i++) { for(int i=0;i<2;i++) {
if(i>=uLightCount) break; if(i>=uLightCount) break;
float angle=acos(clamp(dot(rd,uStarDirections[i]),-1.0,1.0)); float angle=acos(clamp(dot(rd,uStarDirections[i]),-1.0,1.0));

View File

@@ -0,0 +1,17 @@
#version 330 core
noperspective in vec2 gPixel;
flat in vec3 gColor;
flat in float gLength;
flat in float gSigma;
out vec4 fragColor;
void main() {
float closest=clamp(gPixel.x,0.0,gLength);
vec2 delta=vec2(gPixel.x-closest,gPixel.y);
float sigma2=gSigma*gSigma+1.0/12.0;
float profile=exp(-dot(delta,delta)/(2.0*sigma2));
float along=gLength>.5?mix(.32,1.0,smoothstep(0.0,1.0,closest/gLength)):1.0;
float head=exp(-((gPixel.x-gLength)*(gPixel.x-gLength)+gPixel.y*gPixel.y)/(2.0*sigma2));
float brightness=profile*along+head*.15;
if(brightness<.003)discard;
fragColor=vec4(gColor*brightness,0);
}

View File

@@ -0,0 +1,58 @@
#version 330 core
layout(points) in;
layout(triangle_strip,max_vertices=4) out;
in VS_OUT { vec4 previousClip; vec3 color; float size; } star[];
uniform vec3 uViewport;
uniform float uShutter;
uniform float uMaximumTrailPixels;
uniform float uMinimumWidthPixels;
uniform float uMovingBrightness;
noperspective out vec2 gPixel;
flat out vec3 gColor;
flat out float gLength;
flat out float gSigma;
// Clip the swept segment to the viewport before bounding its length. A star
// crossing an edge still leaves a visible trail without a full-screen triangle.
bool clipAxis(float origin,float delta,float bound,inout float lo,inout float hi) {
if(abs(delta)<1e-7)return abs(origin)<=bound;
float a=(-bound-origin)/delta,b=(bound-origin)/delta;
lo=max(lo,min(a,b));hi=min(hi,max(a,b));return lo<=hi;
}
void vertex(vec2 pixel,vec2 uv) {
gl_Position=vec4(pixel/uViewport.xy*2.0,.999999,1);
gPixel=uv;EmitVertex();
}
void main() {
if(max(max(star[0].color.r,star[0].color.g),star[0].color.b)<.00001)return;
vec4 head=gl_in[0].gl_Position,tail=star[0].previousClip;
if(uShutter<=0.0)tail=head;
const float nearPlane=.00001;
if(head.w<=nearPlane&&tail.w<=nearPlane)return;
if(head.w<nearPlane)head=mix(tail,head,(tail.w-nearPlane)/(tail.w-head.w));
if(tail.w<nearPlane)tail=mix(head,tail,(head.w-nearPlane)/(head.w-tail.w));
vec2 h=head.xy/head.w*uViewport.xy*.5;
vec2 t=mix(h,tail.xy/tail.w*uViewport.xy*.5,uShutter);
vec2 delta=h-t;
float lo=0.0,hi=1.0;
vec2 bounds=uViewport.xy*.5+vec2(8.0);
if(!clipAxis(t.x,delta.x,bounds.x,lo,hi)||!clipAxis(t.y,delta.y,bounds.y,lo,hi))return;
h=t+delta*hi;t+=delta*lo;
float distancePixels=length(h-t);
if(distancePixels>uMaximumTrailPixels)t=h-(h-t)*(uMaximumTrailPixels/distancePixels);
gLength=length(h-t);
vec2 direction=gLength>.001?(h-t)/gLength:vec2(1,0);
vec2 perpendicular=vec2(-direction.y,direction.x);
// A minimum Gaussian footprint averages subpixel motion instead of toggling
// a one-pixel point on and off as the camera passes it.
gSigma=max(uMinimumWidthPixels/2.0,star[0].size*.3);
float radius=gSigma*3.0;
float moving=1.0-exp(-gLength/14.0);
float boost=mix(1.0,uMovingBrightness,moving);
gColor=star[0].color*.7*boost;
vertex(t-direction*radius-perpendicular*radius,vec2(-radius,-radius));
vertex(t-direction*radius+perpendicular*radius,vec2(-radius,radius));
vertex(h+direction*radius-perpendicular*radius,vec2(gLength+radius,-radius));
vertex(h+direction*radius+perpendicular*radius,vec2(gLength+radius,radius));
EndPrimitive();
}

View File

@@ -0,0 +1,35 @@
#version 330 core
layout(location=0) in vec3 aPosition;
layout(location=1) in vec3 aColor;
layout(location=2) in float aSize;
uniform vec3 uOrigin;
uniform vec3 uPreviousOrigin;
uniform vec3 uAxisX;
uniform vec3 uAxisY;
uniform vec3 uAxisZ;
uniform vec3 uForward;
uniform vec3 uRight;
uniform vec3 uUp;
uniform vec3 uPreviousForward;
uniform vec3 uPreviousRight;
uniform vec3 uPreviousUp;
uniform float uAspect;
uniform float uTanHalfFov;
uniform float uPreviousAspect;
uniform float uPreviousTanHalfFov;
uniform float uNearFade;
uniform float uVisibility;
out VS_OUT { vec4 previousClip; vec3 color; float size; } star;
vec4 projectStar(vec3 p,vec3 forward,vec3 right,vec3 up,float aspect,float fov) {
float z=dot(p,forward);
return vec4(dot(p,right)/(aspect*fov),dot(p,up)/fov,z*.999999,z);
}
void main() {
vec3 world=uAxisX*aPosition.x+uAxisY*aPosition.y+uAxisZ*aPosition.z;
vec3 current=world-uOrigin;
gl_Position=projectStar(current,uForward,uRight,uUp,uAspect,uTanHalfFov);
star.previousClip=projectStar(world-uPreviousOrigin,uPreviousForward,uPreviousRight,uPreviousUp,uPreviousAspect,uPreviousTanHalfFov);
float visibility=uNearFade>0.0?smoothstep(uNearFade,uNearFade*2.0,length(current)):1.0;
star.color=aColor*visibility*uVisibility;
star.size=aSize;
}

View File

@@ -0,0 +1,57 @@
using Silk.NET.OpenGL;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Graphics;
/// <summary>Draws only stellar sprites as swept Gaussian capsules. No scene-color accumulation.</summary>
internal sealed class StarTrailRenderer : IDisposable
{
private readonly GL _gl;
private ShaderProgram _program;
public StarMotionBlurSettings Settings { get; }
public StarMotionHistory History { get; } = new();
public float Brightness { get; set; } = 1;
private double _cellSize;
public StarTrailRenderer(GL gl, StarMotionBlurSettings settings)
{
settings.Validate(); _gl = gl; Settings = settings; _program = CreateProgram();
}
private ShaderProgram CreateProgram() => ShaderProgram.Create(_gl, ShaderSources.Load("star-trail.vert"),
ShaderSources.Load("star-trail.frag"), ShaderSources.Load("star-trail.geom"));
public void ReloadShaders()
{
var replacement = CreateProgram(); _program.Dispose(); _program = replacement;
}
public void BeginFrame(FlyCamera camera, int width, int height, double seconds)
{
_cellSize = camera.CellSizeMeters;
History.Advance(camera, width, height, seconds, Settings);
}
public void Draw(GpuMesh mesh, UniversePosition center, double scale, PlanetOrientation orientation,
double nearFade = 0, float visibility = 1, bool directional = false)
{
var current = History.Current; var previous = History.Previous;
_program.Use();
_program.Set("uOrigin", directional ? System.Numerics.Vector3.Zero : (current.Position.RelativeTo(center, _cellSize) / scale).ToVector3());
_program.Set("uPreviousOrigin", directional ? System.Numerics.Vector3.Zero : (previous.Position.RelativeTo(center, _cellSize) / scale).ToVector3());
_program.Set("uAxisX", orientation.ToWorld(new(1,0,0)).ToVector3());
_program.Set("uAxisY", orientation.ToWorld(new(0,1,0)).ToVector3());
_program.Set("uAxisZ", orientation.ToWorld(new(0,0,1)).ToVector3());
_program.Set("uForward", current.Forward.ToVector3()); _program.Set("uRight", current.Right.ToVector3()); _program.Set("uUp", current.Up.ToVector3());
_program.Set("uPreviousForward", previous.Forward.ToVector3()); _program.Set("uPreviousRight", previous.Right.ToVector3()); _program.Set("uPreviousUp", previous.Up.ToVector3());
_program.Set("uAspect", (float)current.Width/current.Height); _program.Set("uTanHalfFov", (float)current.TanHalfFov);
_program.Set("uPreviousAspect", (float)previous.Width/previous.Height); _program.Set("uPreviousTanHalfFov", (float)previous.TanHalfFov);
_program.Set("uNearFade", (float)nearFade); _program.Set("uVisibility", visibility*Brightness);
_program.Set("uViewport", new System.Numerics.Vector3(current.Width,current.Height,0));
_program.Set("uShutter", History.Valid ? (float)Settings.ShutterFraction : 0);
_program.Set("uMaximumTrailPixels", (float)Settings.MaximumTrailPixels);
_program.Set("uMinimumWidthPixels", (float)Settings.MinimumWidthPixels);
_program.Set("uMovingBrightness", (float)Settings.MovingBrightness);
mesh.Draw(GLEnum.Points);
}
public void Dispose() => _program.Dispose();
}

View File

@@ -27,6 +27,9 @@ internal sealed class UniverseRenderer : IDisposable
private readonly PerformanceOverlay _performanceOverlay; private readonly PerformanceOverlay _performanceOverlay;
private readonly BenchmarkOverlay _benchmarkOverlay; private readonly BenchmarkOverlay _benchmarkOverlay;
private readonly GpuPassProfiler _gpuProfiler; private readonly GpuPassProfiler _gpuProfiler;
private readonly CosmicRenderer? _cosmos;
private readonly StarTrailRenderer _starTrails;
private readonly Dictionary<SectorCoordinate, StarSystemData> _residentSystems = [];
private ShaderProgram _surface; private ShaderProgram _surface;
private ShaderProgram _star; private ShaderProgram _star;
private ShaderProgram _sky; private ShaderProgram _sky;
@@ -77,10 +80,11 @@ internal sealed class UniverseRenderer : IDisposable
public bool DebugProbeEnabled { get; set; } public bool DebugProbeEnabled { get; set; }
public double CpuRenderMilliseconds { get; private set; } public double CpuRenderMilliseconds { get; private set; }
public float ExposureCompensation { get; set; } = 1f; public float ExposureCompensation { get; set; } = 1f;
public string HardwareName => _gl.GetStringS(GLEnum.Renderer);
internal bool ProfileFrame { get; set; } internal bool ProfileFrame { get; set; }
internal Dictionary<string, double> FrameTimings { get; } = []; internal Dictionary<string, double> FrameTimings { get; } = [];
public UniverseRenderer(GL gl, int width, int height) public UniverseRenderer(GL gl, int width, int height, GalaxyUniverse? galaxies = null, StarMotionBlurSettings? starMotionBlur = null)
{ {
_gl = gl; _gl = gl;
_noise = new(gl); _noise = new(gl);
@@ -96,6 +100,8 @@ internal sealed class UniverseRenderer : IDisposable
_performanceOverlay = new(gl); _performanceOverlay = new(gl);
_benchmarkOverlay = new(gl); _benchmarkOverlay = new(gl);
_gpuProfiler = new(gl); _gpuProfiler = new(gl);
_starTrails = new(gl, starMotionBlur ?? new());
if (galaxies is not null) _cosmos = new(gl, galaxies, _starTrails);
_surface = Program("terrain.vert", "terrain.frag"); _surface = Program("terrain.vert", "terrain.frag");
_star = Program("star.vert", "star.frag"); _star = Program("star.vert", "star.frag");
_points = Program("point.vert", "point.frag"); _points = Program("point.vert", "point.frag");
@@ -173,6 +179,7 @@ internal sealed class UniverseRenderer : IDisposable
("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","black-hole.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));
_starTrails.ReloadShaders();
} }
catch (Exception error) catch (Exception error)
{ {
@@ -196,19 +203,33 @@ internal sealed class UniverseRenderer : IDisposable
{ {
if (coordinate == _activeSystem) return; if (coordinate == _activeSystem) return;
_activeSystem = coordinate; _activeSystem = coordinate;
foreach (var mesh in _planetMeshes.Values) mesh.Dispose();
_planetMeshes.Clear();
foreach (var textures in _planetTextures.Values) textures.Dispose();
foreach (var rings in _ringProfiles.Values) rings.Dispose();
foreach (var rocks in _ringParticles.Values) rocks.Dispose();
_planetTextures.Clear(); _ringProfiles.Clear(); _ringParticles.Clear();
foreach (var texture in _cloudDensityTextures.Values) texture.Dispose();
_cloudDensityTextures.Clear();
_gpuProfiler.Clear(); _gpuProfiler.Clear();
_probeFrameCounter = 0; _probeFrameCounter = 0;
_debugProbePlanetId = null; _debugProbePlanetId = null;
} }
private void RetainSystem(StarSystemData system)
{
if (system.Stars.Count == 0) return;
_residentSystems.Remove(system.Coordinate); _residentSystems.Add(system.Coordinate, system);
if (_residentSystems.Count <= 3) return;
var victim = _residentSystems.First(); _residentSystems.Remove(victim.Key);
foreach (var planet in victim.Value.Planets)
{
if (_planetMeshes.Remove(planet.Id, out var mesh)) mesh.Dispose();
if (_planetTextures.Remove(planet.Id, out var textures)) textures.Dispose();
if (_ringProfiles.Remove(planet.Id, out var rings)) rings.Dispose();
if (_ringParticles.Remove(planet.Id, out var rocks)) rocks.Dispose();
if (_cloudDensityTextures.Remove(planet.Id, out var clouds)) clouds.Dispose();
}
}
public void PreparePlanet(StarSystemData system, PlanetData planet)
{
RetainSystem(system);
if (!planet.IsGasPlanet && !_planetMeshes.ContainsKey(planet.Id)) _planetMeshes[planet.Id] = new(_gl, planet);
if (!_planetTextures.ContainsKey(planet.Id)) _planetTextures[planet.Id] = new(_gl, planet);
}
public void Resize(int width, int height) public void Resize(int width, int height)
{ {
_width = System.Math.Max(width, 1); _width = System.Math.Max(width, 1);
@@ -230,6 +251,7 @@ internal sealed class UniverseRenderer : IDisposable
public void Render(FlyCamera camera, UniverseNeighborhood neighborhood, double deltaSeconds) public void Render(FlyCamera camera, UniverseNeighborhood neighborhood, double deltaSeconds)
{ {
if (_disposed) return; if (_disposed) return;
_starTrails.BeginFrame(camera,_width,_height,deltaSeconds);
var cpuTimer = DebugMode ? System.Diagnostics.Stopwatch.StartNew() : null; var cpuTimer = DebugMode ? System.Diagnostics.Stopwatch.StartNew() : null;
var timer = ProfileFrame ? System.Diagnostics.Stopwatch.StartNew() : null; var timer = ProfileFrame ? System.Diagnostics.Stopwatch.StartNew() : null;
if (timer is not null) { _gl.Finish(); timer.Restart(); FrameTimings.Clear(); } if (timer is not null) { _gl.Finish(); timer.Restart(); FrameTimings.Clear(); }
@@ -248,6 +270,7 @@ internal sealed class UniverseRenderer : IDisposable
var projection = Matrix4x4.CreatePerspectiveFieldOfView((float)camera.FieldOfViewRadians, (float)_width / _height, 0.02f, far); var projection = Matrix4x4.CreatePerspectiveFieldOfView((float)camera.FieldOfViewRadians, (float)_width / _height, 0.02f, far);
var vp = view * projection; var vp = view * projection;
var system = neighborhood.ActiveSystem; var system = neighborhood.ActiveSystem;
RetainSystem(system);
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters); var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var starPositions = system.Stars.Select(s => center.Add(s.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)).ToArray(); var starPositions = system.Stars.Select(s => center.Add(s.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)).ToArray();
BindNoise(); BindNoise();
@@ -262,6 +285,7 @@ internal sealed class UniverseRenderer : IDisposable
_sky.Use(); _sky.Use();
SetCameraRay(_sky, camera); SetCameraRay(_sky, camera);
_sky.Set("uNoise", 4); _sky.Set("uTime", (float)_time); _sky.Set("uNoise", 4); _sky.Set("uTime", (float)_time);
_sky.Set("uBackgroundStrength", _cosmos is null ? 1f : 0f);
_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.IsBlackHole ? Vector3.Zero : s.Color).ToArray()); _sky.SetVectorArray("uStarColors", system.Stars.Select(s => s.IsBlackHole ? Vector3.Zero : s.Color).ToArray());
@@ -272,6 +296,10 @@ internal sealed class UniverseRenderer : IDisposable
DrawStarfield(camera, neighborhood, vp, far); DrawStarfield(camera, neighborhood, vp, far);
_gpuProfiler.EndPass(); _gpuProfiler.EndPass();
Mark("sky"); Mark("sky");
_gpuProfiler.BeginPass("GALAXIES / NEBULAE");
_cosmos?.Draw(camera, _width, _height, deltaSeconds);
_gpuProfiler.EndPass();
Mark("galaxies and nebulae");
_gl.DepthMask(true); _gl.DepthMask(true);
_gl.Disable(GLEnum.Blend); _gl.Disable(GLEnum.Blend);
_gpuProfiler.BeginPass("PLANETS TOTAL"); _gpuProfiler.BeginPass("PLANETS TOTAL");
@@ -344,7 +372,7 @@ internal sealed class UniverseRenderer : IDisposable
_gl.DepthMask(true); _gl.Enable(GLEnum.DepthTest); _gl.DepthMask(true); _gl.Enable(GLEnum.DepthTest);
} }
public void DrawBenchmarkOverlay(BenchmarkRunner benchmark) public void DrawBenchmarkOverlay(IBenchmarkSession benchmark)
{ {
_gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.Blend); _gl.DepthMask(false); _gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.Blend); _gl.DepthMask(false);
_overlay.Use(); _overlay.Use();
@@ -381,9 +409,9 @@ internal sealed class UniverseRenderer : IDisposable
{ {
_gl.Enable(GLEnum.Blend); _gl.Enable(GLEnum.Blend);
_gl.BlendFunc(GLEnum.One, GLEnum.One); _gl.BlendFunc(GLEnum.One, GLEnum.One);
_points.Use(); _points.SetMatrix("uViewProjection", vp); _points.Set("uFar", far); _points.Set("uScale", far * 0.55f); _starTrails.Brightness = StarVisibility(camera, neighborhood.ActiveSystem);
_points.Set("uBrightness", StarVisibility(camera, neighborhood.ActiveSystem)); if (_cosmos is null) _starTrails.Draw(_backgroundStars,camera.Position,1,PlanetOrientation.Identity,directional:true);
_backgroundStars.Draw(GLEnum.Points); var scale = _cosmos is null ? (double)far : UniverseScale.LightYearMeters;
var points = new List<float>(26 * 14); var points = new List<float>(26 * 14);
foreach (var system in neighborhood.NearbySystems) foreach (var system in neighborhood.NearbySystems)
{ {
@@ -392,12 +420,11 @@ internal sealed class UniverseRenderer : IDisposable
{ {
if (star.IsBlackHole) continue; 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/scale, star.Color * 1.3f, 3.2f);
} }
} }
_navigationStars.ReplaceVertices(points.ToArray()); _navigationStars.ReplaceVertices(points.ToArray());
_points.Set("uScale", 1f); _starTrails.Draw(_navigationStars,camera.Position,scale,PlanetOrientation.Identity);
_navigationStars.Draw(GLEnum.Points);
_gl.Disable(GLEnum.Blend); _gl.Disable(GLEnum.Blend);
} }
@@ -421,6 +448,7 @@ internal sealed class UniverseRenderer : IDisposable
if (planet.IsGasPlanet) continue; if (planet.IsGasPlanet) continue;
var relative = relativePlanets[i]; var relative = relativePlanets[i];
if (DVector3.Dot(relative, camera.Forward) < -planet.MaximumTerrainRadiusMeters) continue; if (DVector3.Dot(relative, camera.Forward) < -planet.MaximumTerrainRadiusMeters) continue;
if (planet.RadiusMeters / System.Math.Max(1,relative.Length) * _height / (2 * System.Math.Tan(camera.FieldOfViewRadians*.5)) < .5) continue;
if (!_planetMeshes.TryGetValue(planet.Id, out var mesh)) if (!_planetMeshes.TryGetValue(planet.Id, out var mesh))
{ {
mesh = new(_gl, planet); _planetMeshes.Add(planet.Id, mesh); mesh = new(_gl, planet); _planetMeshes.Add(planet.Id, mesh);
@@ -584,7 +612,8 @@ internal sealed class UniverseRenderer : IDisposable
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters); var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var candidates = system.Planets.Where(p => p.IsGasPlanet).Select(p => (Planet: p, var candidates = system.Planets.Where(p => p.IsGasPlanet).Select(p => (Planet: p,
Relative: center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters))) Relative: center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)))
.Where(p => DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.RadiusMeters * 1.3) .Where(p => DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.RadiusMeters * 1.3
&& p.Planet.RadiusMeters / System.Math.Max(1,p.Relative.Length) * _height / (2 * System.Math.Tan(camera.FieldOfViewRadians*.5)) >= .5)
.OrderByDescending(p => p.Relative.Length); .OrderByDescending(p => p.Relative.Length);
foreach (var (planet, relative) in candidates) foreach (var (planet, relative) in candidates)
{ {
@@ -1078,6 +1107,8 @@ internal sealed class UniverseRenderer : IDisposable
_planetTextures.Clear(); _cloudDensityTextures.Clear(); _ringProfiles.Clear(); _ringParticles.Clear(); _planetTextures.Clear(); _cloudDensityTextures.Clear(); _ringProfiles.Clear(); _ringParticles.Clear();
_sphere.Dispose(); _fullscreen.Dispose(); _backgroundStars.Dispose(); _navigationStars.Dispose(); _crosshair.Dispose(); _pauseMenu.Dispose(); _sphere.Dispose(); _fullscreen.Dispose(); _backgroundStars.Dispose(); _navigationStars.Dispose(); _crosshair.Dispose(); _pauseMenu.Dispose();
_performanceOverlay.Dispose(); _gpuProfiler.Dispose(); _performanceOverlay.Dispose(); _gpuProfiler.Dispose();
_cosmos?.Dispose();
_starTrails.Dispose();
_surface.Dispose(); _star.Dispose(); _sky.Dispose(); _points.Dispose(); _atmosphere.Dispose(); _bright.Dispose(); _blur.Dispose(); _final.Dispose(); _overlay.Dispose(); _ring.Dispose(); _surface.Dispose(); _star.Dispose(); _sky.Dispose(); _points.Dispose(); _atmosphere.Dispose(); _bright.Dispose(); _blur.Dispose(); _final.Dispose(); _overlay.Dispose(); _ring.Dispose();
_benchmarkOverlay.Dispose(); _benchmarkOverlay.Dispose();
_gas.Dispose(); _rock.Dispose(); _rockPoints.Dispose(); _emptyRing.Dispose(); _gasInput.Dispose(); _gas.Dispose(); _rock.Dispose(); _rockPoints.Dispose(); _emptyRing.Dispose(); _gasInput.Dispose();

View File

@@ -0,0 +1,2 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Universe.Tests")]

View File

@@ -6,7 +6,7 @@ using Universe.Core.World;
namespace Universe.App.Runtime; namespace Universe.App.Runtime;
/// <summary>Repeatable camera path and frame-time capture for a selected planetary terrain scene.</summary> /// <summary>Repeatable camera path and frame-time capture for a selected planetary terrain scene.</summary>
internal sealed class BenchmarkRunner internal sealed class BenchmarkRunner : IBenchmarkSession
{ {
private const double SpaceSeconds = 8; private const double SpaceSeconds = 8;
private const double DescentSeconds = 40; private const double DescentSeconds = 40;
@@ -77,7 +77,7 @@ internal sealed class BenchmarkRunner
_orbitBitangent = DVector3.Cross(_targetRadial, _orbitTangent).Normalized(); _orbitBitangent = DVector3.Cross(_targetRadial, _orbitTangent).Normalized();
var initialRadius = _planet.RadiusMeters * 4.8; var initialRadius = _planet.RadiusMeters * 4.8;
camera.SetPlayerPose(_body.Add(_spaceRadial * initialRadius, cellSizeMeters), -_spaceRadial, _spaceUp); camera.SetPlayerPose(_body.Add(_spaceRadial * initialRadius, cellSizeMeters), -_spaceRadial, _spaceUp, resetMotionHistory: true);
} }
public bool IsRunning => !_stopped && _elapsed < TotalSeconds; public bool IsRunning => !_stopped && _elapsed < TotalSeconds;
@@ -169,7 +169,7 @@ internal sealed class BenchmarkRunner
_cellSize, cameraRadiusMeters: 1.5, samples: 32); _cellSize, cameraRadiusMeters: 1.5, samples: 32);
if (_elapsed >= TotalSeconds) if (_elapsed >= TotalSeconds)
{ {
camera.SetPlayerPose(_returnPosition, _returnForward, _returnUp); camera.SetPlayerPose(_returnPosition, _returnForward, _returnUp, resetMotionHistory: true);
return; return;
} }
var finalPosition = proposed; var finalPosition = proposed;

View File

@@ -18,11 +18,15 @@ public sealed class FlyCamera
{ {
Position = initialPosition; Position = initialPosition;
CellSizeMeters = cellSizeMeters; CellSizeMeters = cellSizeMeters;
TravelLimitMeters = cellSizeMeters * .45;
SpeedMetersPerSecond = 250_000; SpeedMetersPerSecond = 250_000;
} }
public UniversePosition Position { get; private set; } public UniversePosition Position { get; private set; }
public double CellSizeMeters { get; } public double CellSizeMeters { get; }
public double SpeedMetersPerSecond { get; private set; } public double SpeedMetersPerSecond { get; private set; }
public double TravelLimitMeters { get; set; }
public long MotionHistoryVersion { get; private set; }
public void RestoreDefaultFieldOfView() => FieldOfViewRadians = _defaultFieldOfView;
public bool IsFocusing => _flight is not null; public bool IsFocusing => _flight is not null;
public DVector3 Forward => _forward; public DVector3 Forward => _forward;
public DVector3 Right => DVector3.Cross(_forward, _up).Normalized(); public DVector3 Right => DVector3.Cross(_forward, _up).Normalized();
@@ -37,16 +41,18 @@ public sealed class FlyCamera
_forward = RotateAround(_forward, axis, -mouseDeltaY * 0.0022).Normalized(); _forward = RotateAround(_forward, axis, -mouseDeltaY * 0.0022).Normalized();
_up = RotateAround(beforePitchUp, axis, -mouseDeltaY * 0.0022).Normalized(); _up = RotateAround(beforePitchUp, axis, -mouseDeltaY * 0.0022).Normalized();
} }
public void AdjustSpeed(double delta) => SpeedMetersPerSecond = System.Math.Clamp(SpeedMetersPerSecond * System.Math.Pow(1.7, delta), 0.1, UniverseScale.LightSpeedMetersPerSecond * 1_000_000); public void AdjustSpeed(double delta) => SpeedMetersPerSecond = System.Math.Clamp(SpeedMetersPerSecond * System.Math.Pow(1.7, delta), 0.1, UniverseScale.LightSpeedMetersPerSecond * 1e15);
public void SetPlayerPose(UniversePosition position, DVector3 forward, DVector3 up) public void SetPlayerPose(UniversePosition position, DVector3 forward, DVector3 up, bool resetMotionHistory = false)
{ {
if (resetMotionHistory) MotionHistoryVersion++;
_flight = null; _flight = null;
Position = position; _forward = forward.Normalized(); _up = up.Normalized(); Position = position; _forward = forward.Normalized(); _up = up.Normalized();
} }
public void SetInitialView(StarSystemData system) public void SetInitialView(StarSystemData system)
{ {
MotionHistoryVersion++;
var planet = system.Planets[0]; var planet = system.Planets[0];
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters); var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters);
var sun = (system.Stars[0].PositionMeters - planet.PositionMeters).Normalized(); var sun = (system.Stars[0].PositionMeters - planet.PositionMeters).Normalized();
@@ -59,6 +65,7 @@ public sealed class FlyCamera
public void SetInitialView(StarSystemData system, StartupConfiguration startup) public void SetInitialView(StarSystemData system, StartupConfiguration startup)
{ {
MotionHistoryVersion++;
var planet = system.Planets.FirstOrDefault(p => p.Id == startup.PlanetId) var planet = system.Planets.FirstOrDefault(p => p.Id == startup.PlanetId)
?? throw new InvalidDataException($"Startup planet '{startup.PlanetId}' does not exist in system '{system.Id}'."); ?? throw new InvalidDataException($"Startup planet '{startup.PlanetId}' does not exist in system '{system.Id}'.");
var longitude = startup.LongitudeDegrees * System.Math.PI / 180; var longitude = startup.LongitudeDegrees * System.Math.PI / 180;
@@ -302,6 +309,22 @@ public sealed class FlyCamera
star.RadiusMeters * .08, duration: 9, fieldOfView: 32 * System.Math.PI / 180, departure: departure); star.RadiusMeters * .08, duration: 9, fieldOfView: 32 * System.Math.PI / 180, departure: departure);
} }
public void RequestCosmicView(UniversePosition center,double radius,PlanetOrientation orientation,bool nebula,IEnumerable<StarSystemData> departureSystems)
{
var radial=orientation.ToWorld((nebula?new DVector3(.1,.05,1):new DVector3(.1,1,.3)).Normalized());
var up=orientation.ToWorld(nebula?new(0,1,0):new DVector3(0,0,-1));
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(),center.RelativeTo(origin,CellSizeMeters).Normalized(),start.Length,solid.MaximumTerrainRadiusMeters*1.12);
}
BeginFlight(center,radial,radius*2.5,radius*3.5,-radial,up,radius*.2,duration:8,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,
@@ -361,7 +384,8 @@ public sealed class FlyCamera
return; return;
} }
if (movement.LengthSquared < 1e-12) return; if (movement.LengthSquared < 1e-12) return;
var distance = System.Math.Min(SpeedMetersPerSecond * System.Math.Min(deltaSeconds, 0.1), CellSizeMeters * 0.45); var nearSurface = systems.Any(s => s.Planets.Any(p => Position.RelativeTo(World(s, p.PositionMeters), CellSizeMeters).Length < p.RadiusMeters * 10));
var distance = System.Math.Min(SpeedMetersPerSecond * System.Math.Min(deltaSeconds, 0.1), nearSurface ? CellSizeMeters * .45 : TravelLimitMeters);
var end = Position.Add(movement.Normalized() * distance, CellSizeMeters); var end = Position.Add(movement.Normalized() * distance, CellSizeMeters);
Position = SweepNeighborhood(Position, end, systems).Position; Position = SweepNeighborhood(Position, end, systems).Position;
} }

View File

@@ -0,0 +1,188 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Runtime;
internal sealed class GalaxyJourneyBenchmark : IBenchmarkSession
{
private readonly GalaxyUniverse _galaxies;
private readonly UniversePosition _home, _homeBody, _milkyView, _otherView, _targetBody;
private readonly DVector3 _homeForward, _homeUp, _homeRadial, _targetRadial, _targetHeading;
private readonly double _homeRadius;
private readonly List<(double Time, double Milliseconds, string Phase)> _frames = [];
private readonly List<double> _graph = [];
private readonly HashSet<string> _visited = [];
private double _elapsed, _sampleTime, _totalFrameTime;
private double _lastSampleSequenceTime;
private int _sampleFrames;
private bool _stopped, _finalRecorded;
public GalaxyData HomeGalaxy { get; }
public GalaxyData TargetGalaxy { get; }
public StarSystemData TargetSystem { get; }
public PlanetData TargetPlanet { get; }
public string? ReportDirectory { get; private set; }
public object? RenderEnvironment { get; set; }
public GalaxyJourneyBenchmark(GalaxyUniverse galaxies, UniverseGenerator generator, StarSystemData homeSystem, PlanetData earth,
UniversePosition home, DVector3 homeForward, DVector3 homeUp)
{
_galaxies = galaxies; _home = home; _homeForward = homeForward; _homeUp = homeUp;
_homeBody = UniversePosition.AtCellCenter(homeSystem.Coordinate, galaxies.CellSize).Add(earth.PositionMeters, galaxies.CellSize);
_homeRadial = home.RelativeTo(_homeBody, galaxies.CellSize).Normalized();
_homeRadius = home.RelativeTo(_homeBody, galaxies.CellSize).Length;
HomeGalaxy = galaxies.GalaxyAt(home);
var region = HomeGalaxy.Region;
TargetGalaxy = galaxies.Get(new(checked(region.X + 1), region.Y, region.Z));
TargetSystem = generator.Generate(galaxies.SelectSystem(TargetGalaxy));
TargetPlanet = TargetSystem.Planets.Where(p => !p.IsGasPlanet && p.ParentPlanetIndex is null).OrderByDescending(p => p.RadiusMeters).First();
_targetBody = UniversePosition.AtCellCenter(TargetSystem.Coordinate, galaxies.CellSize).Add(TargetPlanet.PositionMeters, galaxies.CellSize);
// Exercise the illuminated terrain/shadow path, rather than accidentally
// benchmarking the cheap night-side shading branch.
var sun=(TargetSystem.Stars[TargetPlanet.HostStarIndex].PositionMeters-TargetPlanet.PositionMeters).Normalized();
var side=DVector3.Cross(sun,System.Math.Abs(sun.Y)<.9?new(0,1,0):new DVector3(1,0,0)).Normalized();
_targetRadial=(sun*.6+side*.8).Normalized();
_targetHeading=DVector3.Cross(_targetRadial,sun).Normalized();
_milkyView = HomeGalaxy.Center.Add(HomeGalaxy.Orientation.ToWorld(new DVector3(.1,1,.3).Normalized()) * HomeGalaxy.RadiusMeters * 2.5, galaxies.CellSize);
_otherView = TargetGalaxy.Center.Add(TargetGalaxy.Orientation.ToWorld(new DVector3(.3,.8,1).Normalized()) * TargetGalaxy.RadiusMeters * 2.5, galaxies.CellSize);
}
public bool IsRunning => !_stopped && _elapsed < DurationSeconds;
public bool IsComplete => !_stopped && _elapsed >= DurationSeconds;
public bool HasRun => true;
public double ElapsedSeconds => _elapsed;
public double DurationSeconds => 94;
public long GraphRevision { get; private set; }
public IReadOnlyList<double> GraphSamples => _graph;
public string TargetLabel => TargetPlanet.Name.ToUpperInvariant();
public double TargetEarthRadii => TargetPlanet.RadiusMeters / UniverseScale.EarthRadiusMeters;
public double CurrentFps { get; private set; }
public double AverageFps => _totalFrameTime <= 0 ? 0 : _frames.Count / _totalFrameTime;
public double MinimumFps => _frames.Count == 0 ? 0 : 1000 / _frames.Max(f => f.Milliseconds);
public double OnePercentLowFps => Low(_frames.Select(f => f.Milliseconds).ToArray());
public string Phase => _stopped ? "STOPPED" : IsComplete ? "COMPLETE" : PhaseAt(_elapsed);
public IReadOnlyCollection<string> VisitedPhases => _visited;
private static string PhaseAt(double t) => t < 18 ? "LEAVE EARTH" : t < 25 ? "MILKY WAY" : t < 40 ? "BETWEEN GALAXIES"
: t < 55 ? "ENTER GALAXY" : t < 65 ? "TARGET SYSTEM" : t < 85 ? "LANDING" : "GROUND WALK";
public void Stop() => _stopped = true;
public void Update(double seconds, FlyCamera camera)
{
if (!IsRunning || !double.IsFinite(seconds) || seconds <= 0) return;
_elapsed = System.Math.Min(DurationSeconds, _elapsed + seconds);
_visited.Add(PhaseAt(System.Math.Min(_elapsed, DurationSeconds - .001)));
var cell = _galaxies.CellSize;
UniversePosition position; DVector3 forward; DVector3 up = _homeUp;
if (_elapsed < 18)
{
var t = Smooth(_elapsed / 18);
var viewRadial = _milkyView.RelativeTo(_homeBody,cell).Normalized();
var radius = LogLerp(_homeRadius, _milkyView.RelativeTo(_homeBody,cell).Length, t);
var direction = DVector3.Lerp(_homeRadial,viewRadial,Smooth(System.Math.Clamp((t-.6)/.4,0,1))).Normalized();
position = _homeBody.Add(direction * radius, cell);
var focus = System.Math.Clamp((System.Math.Log(System.Math.Max(radius,1)) - System.Math.Log(UniverseScale.LightYearMeters)) / 12, 0, 1);
forward = DVector3.Lerp(_homeForward, HomeGalaxy.Center.RelativeTo(position, cell).Normalized(), Smooth(focus)).Normalized();
}
else if (_elapsed < 25)
{
position = _milkyView;
forward = HomeGalaxy.Center.RelativeTo(position, cell).Normalized();
}
else if (_elapsed < 40)
{
var t = Smooth((_elapsed - 25) / 15);
position = Interpolate(_milkyView, _otherView, t, cell);
var from = HomeGalaxy.Center.RelativeTo(_milkyView, cell).Normalized();
var to = _targetBody.RelativeTo(_otherView, cell).Normalized();
forward = DVector3.Lerp(from, to, t).Normalized();
}
else if (_elapsed < 55)
{
var t = Smooth((_elapsed - 40) / 15);
var radial = _otherView.RelativeTo(_targetBody, cell).Normalized();
var radius = LogLerp(_otherView.RelativeTo(_targetBody, cell).Length, TargetPlanet.RadiusMeters * 4.8, t);
// Rotate at galaxy distances; keep the final solar approach radial and precise.
var direction = DVector3.Lerp(radial, _targetRadial, Smooth(System.Math.Min(t * 3, 1))).Normalized();
position = _targetBody.Add(direction * radius, cell);
forward = _targetBody.RelativeTo(position, cell).Normalized();
up = new(0,1,0);
}
else if (_elapsed < 65)
{
position = _targetBody.Add(_targetRadial * TargetPlanet.RadiusMeters * 4.8, cell);
forward = -_targetRadial; up = new(0,1,0);
}
else
{
var t = System.Math.Clamp((_elapsed - 65) / 20, 0, 1);
var surface = TerrainHeight.Sample(TargetPlanet, _targetRadial);
var radius = t < .5 ? TargetPlanet.RadiusMeters * 4.8 + (surface + 10001.8 - TargetPlanet.RadiusMeters * 4.8) * Smooth(t * 2)
: surface + 1.8 + 10000 * (1 - Smooth((t - .5) * 2));
var radial = _targetRadial;
if (_elapsed >= 85)
{
var walk = (_elapsed - 85) / 9;
radial = (_targetRadial + _targetHeading * (walk * 12 / TargetPlanet.RadiusMeters)).Normalized();
radius = TerrainHeight.Sample(TargetPlanet, radial) + 1.8;
}
position = _targetBody.Add(radial * radius, cell);
var landingLook = (_targetHeading * System.Math.Cos(.2) - radial * System.Math.Sin(.2)).Normalized();
forward = DVector3.Lerp(-radial, landingLook, Smooth(System.Math.Clamp((t - .4) / .6,0,1))).Normalized();
if (_elapsed >= 85)
{
var yaw = System.Math.Sin((_elapsed - 85) * .7) * .6;
var right = DVector3.Cross(_targetHeading, radial).Normalized();
forward = (_targetHeading * System.Math.Cos(yaw) + right * System.Math.Sin(yaw) - radial * .15).Normalized();
}
up = radial;
}
if (_elapsed >= DurationSeconds) { position = _home; forward = _homeForward; up = _homeUp; }
up = (up - forward * DVector3.Dot(up,forward)).Normalized();
if (up.LengthSquared < .1) up = DVector3.Cross(forward,new(1,0,0)).Normalized();
camera.SetPlayerPose(position, forward, up, resetMotionHistory: IsComplete);
}
public void RecordFrame(double seconds)
{
if (_stopped || _finalRecorded || !double.IsFinite(seconds) || seconds <= 0) return;
CurrentFps = 1 / seconds; _totalFrameTime += seconds;
_frames.Add((_elapsed, seconds * 1000, PhaseAt(System.Math.Min(_elapsed,DurationSeconds - .001))));
_sampleTime += seconds; _sampleFrames++;
if (_elapsed - _lastSampleSequenceTime >= .2 || IsComplete)
{
_graph.Add(_sampleFrames / _sampleTime); _sampleTime = 0; _sampleFrames = 0; _lastSampleSequenceTime = _elapsed; GraphRevision++;
}
if (IsComplete) _finalRecorded = true;
}
public object[] PhaseResults() => _frames.GroupBy(f => f.Phase).Select(group =>
{
var values = group.Select(f => f.Milliseconds).Order().ToArray();
var p99 = values[System.Math.Min(values.Length - 1,(int)System.Math.Ceiling(values.Length * .99) - 1)];
return (object)new { Phase = group.Key, Frames = values.Length, AverageFps = 1000 / values.Average(), LowOnePercentFps = Low(values),
P99Milliseconds = p99, MaximumMilliseconds = values.Max(),
Passed = Low(values) >= _galaxies.Settings.MinimumBenchmarkFps && values.Max() <= _galaxies.Settings.MaximumBenchmarkFrameMilliseconds };
}).ToArray();
public bool PerformancePassed => IsComplete && _frames.Count > 0 && _frames.GroupBy(f => f.Phase).All(g =>
Low(g.Select(f => f.Milliseconds).ToArray()) >= _galaxies.Settings.MinimumBenchmarkFps
&& g.Max(f => f.Milliseconds) <= _galaxies.Settings.MaximumBenchmarkFrameMilliseconds);
public string SaveReport(string? root = null)
{
if (ReportDirectory is not null) return ReportDirectory;
var directory = Path.Combine(root ?? Path.Combine(AppContext.BaseDirectory,"benchmark-results"),"galaxy-journey-"+DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
Directory.CreateDirectory(directory);
var csv = new StringBuilder("timeSeconds,phase,frameMilliseconds,fps\n");
foreach (var f in _frames) csv.AppendLine(FormattableString.Invariant($"{f.Time:F4},{f.Phase},{f.Milliseconds:F4},{1000/f.Milliseconds:F4}"));
File.WriteAllText(Path.Combine(directory,"frames.csv"),csv.ToString());
File.WriteAllText(Path.Combine(directory,"summary.json"),JsonSerializer.Serialize(new { Completed = IsComplete, PerformancePassed,
UniverseSeed = _galaxies.Seed.ToString("X16"), RenderEnvironment,
Quality = new { _galaxies.Settings.GalaxyStarBudget, _galaxies.Settings.LocalStarBudget, _galaxies.Settings.VolumeSteps, _galaxies.Settings.SystemSpacingLightYears },
TargetGalaxy = TargetGalaxy.Id, TargetSystem = TargetSystem.Id, TargetPlanet = TargetPlanet.Id,
MinimumFps = _galaxies.Settings.MinimumBenchmarkFps, MaximumFrameMilliseconds = _galaxies.Settings.MaximumBenchmarkFrameMilliseconds,
Visited = _visited, Phases = PhaseResults() },new JsonSerializerOptions { WriteIndented = true }));
ReportDirectory = directory; return directory;
}
private static double Low(double[] milliseconds) => milliseconds.Length == 0 ? 0 : 1000 / milliseconds.OrderDescending().Take(System.Math.Max(1,(int)System.Math.Ceiling(milliseconds.Length * .01))).Average();
private static double Smooth(double t) { t=System.Math.Clamp(t,0,1);return t*t*t*(t*(t*6-15)+10); }
private static double LogLerp(double a,double b,double t) => System.Math.Exp(System.Math.Log(a)*(1-t)+System.Math.Log(b)*t);
private static UniversePosition Interpolate(UniversePosition a,UniversePosition b,double t,double cell) => t<.5?a.Add(b.RelativeTo(a,cell)*t,cell):b.Add(a.RelativeTo(b,cell)*(1-t),cell);
}

View File

@@ -0,0 +1,24 @@
using Universe.Core.Generation;
namespace Universe.App.Runtime;
internal interface IBenchmarkSession
{
bool IsRunning { get; }
bool IsComplete { get; }
bool HasRun { get; }
double ElapsedSeconds { get; }
double DurationSeconds { get; }
long GraphRevision { get; }
IReadOnlyList<double> GraphSamples { get; }
string TargetLabel { get; }
double TargetEarthRadii { get; }
double CurrentFps { get; }
double AverageFps { get; }
double MinimumFps { get; }
double OnePercentLowFps { get; }
string Phase { get; }
void Stop();
void Update(double seconds, FlyCamera camera);
void RecordFrame(double seconds);
}

View File

@@ -0,0 +1,29 @@
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Runtime;
internal readonly record struct StarCameraPose(UniversePosition Position, DVector3 Forward, DVector3 Right, DVector3 Up,
double TanHalfFov, int Width, int Height, long CutVersion);
internal sealed class StarMotionHistory
{
private bool _initialized;
public StarCameraPose Current { get; private set; }
public StarCameraPose Previous { get; private set; }
public bool Valid { get; private set; }
public void Advance(FlyCamera camera, int width, int height, double frameSeconds, StarMotionBlurSettings settings)
{
var pose = new StarCameraPose(camera.Position, camera.Forward, camera.Right, camera.Up,
System.Math.Tan(camera.FieldOfViewRadians * .5), width, height, camera.MotionHistoryVersion);
Previous = Current;
Valid = _initialized && settings.Enabled && double.IsFinite(frameSeconds) && frameSeconds > 0
&& frameSeconds <= settings.HistoryResetSeconds && Previous.Width == width && Previous.Height == height
&& Previous.CutVersion == pose.CutVersion && System.Math.Abs(Previous.TanHalfFov / pose.TanHalfFov - 1) < .15;
Current = pose;
if (!Valid) Previous = Current;
_initialized = true;
}
}

View File

@@ -4,6 +4,7 @@
<ProjectReference Include="..\Universe.Core\Universe.Core.csproj" /> <ProjectReference Include="..\Universe.Core\Universe.Core.csproj" />
<EmbeddedResource Include="Graphics\Shaders\*.glsl" /> <EmbeddedResource Include="Graphics\Shaders\*.glsl" />
<Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\KnownGalaxies.json" Link="KnownGalaxies.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\..\Content\**\*.json;..\..\Content\**\*.jpg;..\..\Content\**\*.png;..\..\Content\**\*.f32" Link="Content\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <Content Include="..\..\Content\**\*.json;..\..\Content\**\*.jpg;..\..\Content\**\*.png;..\..\Content\**\*.f32" Link="Content\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>

View File

@@ -18,6 +18,8 @@ public sealed class UniverseApplication : IDisposable
private readonly IWindow _window; private readonly IWindow _window;
private readonly UniverseGenerator _generator; private readonly UniverseGenerator _generator;
private readonly UniverseStreamer _streamer; private readonly UniverseStreamer _streamer;
private readonly GalaxyUniverse _galaxies;
private readonly StarMotionBlurSettings _starMotionBlur;
private readonly IReadOnlyList<string> _planetHotkeys; private readonly IReadOnlyList<string> _planetHotkeys;
private readonly StarSystemData _homeSystem; private readonly StarSystemData _homeSystem;
private readonly PlanetData _homeEarth; private readonly PlanetData _homeEarth;
@@ -26,7 +28,8 @@ public sealed class UniverseApplication : IDisposable
private readonly DVector3 _homeCameraUp; private readonly DVector3 _homeCameraUp;
private FlyCamera _camera; private FlyCamera _camera;
private PlayerController? _player; private PlayerController? _player;
private BenchmarkRunner? _benchmark; private IBenchmarkSession? _benchmark;
private bool _benchmarkNeedsInitialFrame;
private bool _menuOpen; private bool _menuOpen;
private bool _debugMode; private bool _debugMode;
private int _menuSelection; private int _menuSelection;
@@ -49,19 +52,23 @@ public sealed class UniverseApplication : IDisposable
{ {
var configPath = Path.Combine(AppContext.BaseDirectory, "config.json"); var configPath = Path.Combine(AppContext.BaseDirectory, "config.json");
var configuration = EngineConfiguration.Load(configPath); var configuration = EngineConfiguration.Load(configPath);
_starMotionBlur = configuration.StarMotionBlur;
_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, configuration.BlackHoles); _galaxies = new(configuration.Seed, configuration.CellSizeMeters, configuration.Galaxies,
Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.Galaxies.KnownGalaxiesFile)));
_generator = new(configuration.Seed, configuration.CellSizeMeters, catalog, configuration.BlackHoles, _galaxies);
_streamer = new(_generator); _streamer = new(_generator);
var initialSystem = configuration.ResolveStartup(catalog).System; var initialSystem = configuration.ResolveStartup(catalog).System;
_homeSystem = catalog.GetById("sol"); _homeSystem = _generator.Generate(catalog.GetById("sol").Coordinate);
_homeEarth = _homeSystem.Planets.FirstOrDefault(p => p.Id.Equals("earth", StringComparison.OrdinalIgnoreCase)) _homeEarth = _homeSystem.Planets.FirstOrDefault(p => p.Id.Equals("earth", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException("The known solar system must define an Earth planet with id 'earth'."); ?? throw new InvalidDataException("The known solar system must define an Earth planet with id 'earth'.");
var startCoordinate = initialSystem.Coordinate; var startCoordinate = initialSystem.Coordinate;
var cellCenter = UniversePosition.AtCellCenter(startCoordinate, _generator.CellSizeMeters); var cellCenter = UniversePosition.AtCellCenter(startCoordinate, _generator.CellSizeMeters);
var startPosition = cellCenter.Add(new DVector3(0, 0, 4 * UniverseScale.AstronomicalUnitMeters), _generator.CellSizeMeters); var startPosition = cellCenter.Add(new DVector3(0, 0, 4 * UniverseScale.AstronomicalUnitMeters), _generator.CellSizeMeters);
_camera = new(startPosition, _generator.CellSizeMeters); _camera = new(startPosition, _generator.CellSizeMeters);
_camera.TravelLimitMeters = _galaxies.Settings.RegionSizeLightYears * UniverseScale.LightYearMeters * .25;
_neighborhood = _streamer.Load(startCoordinate); _neighborhood = _streamer.Load(startCoordinate);
_camera.SetInitialView(_neighborhood.ActiveSystem, configuration.Startup); _camera.SetInitialView(_neighborhood.ActiveSystem, configuration.Startup);
_homeCameraPosition = _camera.Position; _homeCameraPosition = _camera.Position;
@@ -72,7 +79,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" or "--black-hole-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" or "--galaxy-journey-report" or "--star-trails-report"),
Size = new Vector2D<int>(1440, 900), Size = new Vector2D<int>(1440, 900),
VSync = true, VSync = true,
PreferredDepthBufferBits = 24, PreferredDepthBufferBits = 24,
@@ -92,7 +99,18 @@ 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, _galaxies, _starMotionBlur);
if (Environment.GetCommandLineArgs().Contains("--star-trails-report"))
{
GraphicsReport.RunStarTrails(gl,_starMotionBlur,_window.FramebufferSize.X,_window.FramebufferSize.Y);
_window.Close(); return;
}
if (Environment.GetCommandLineArgs().Contains("--galaxy-journey-report"))
{
var journey = CreateGalaxyJourney();
GraphicsReport.RunGalaxyJourney(gl, _renderer, _camera, _streamer, journey, _window.FramebufferSize.X, _window.FramebufferSize.Y);
_window.Close(); return;
}
if (Environment.GetCommandLineArgs().Contains("--black-hole-report")) if (Environment.GetCommandLineArgs().Contains("--black-hole-report"))
{ {
var target = _streamer.FindNearestBlackHole(_camera.Position) var target = _streamer.FindNearestBlackHole(_camera.Position)
@@ -138,6 +156,7 @@ public sealed class UniverseApplication : IDisposable
if (_benchmark?.IsRunning == true) if (_benchmark?.IsRunning == true)
{ {
if (_benchmarkNeedsInitialFrame) return;
_benchmark.Update(deltaSeconds, _camera); _benchmark.Update(deltaSeconds, _camera);
UpdateNeighborhood(); UpdateNeighborhood();
return; return;
@@ -190,7 +209,10 @@ public sealed class UniverseApplication : IDisposable
private void OnRender(double deltaSeconds) private void OnRender(double deltaSeconds)
{ {
_frameTime += (System.Math.Clamp(deltaSeconds, 0.001, 1) - _frameTime) * 0.08; _frameTime += (System.Math.Clamp(deltaSeconds, 0.001, 1) - _frameTime) * 0.08;
_benchmark?.RecordFrame(deltaSeconds); // The first interval still includes menu preparation and GPU preloading.
// Render one initial frame before starting the measurement clock.
if (!_benchmarkNeedsInitialFrame) _benchmark?.RecordFrame(deltaSeconds);
if (_benchmark is GalaxyJourneyBenchmark { IsComplete: true, ReportDirectory: null } journey) journey.SaveReport();
if (_renderer is not null) if (_renderer is not null)
{ {
_renderer.DebugMode = _debugMode; _renderer.DebugMode = _debugMode;
@@ -200,6 +222,7 @@ public sealed class UniverseApplication : IDisposable
_renderer?.DrawDebugOverlay(1 / _frameTime); _renderer?.DrawDebugOverlay(1 / _frameTime);
if (_benchmark is { HasRun: true } benchmark) _renderer?.DrawBenchmarkOverlay(benchmark); if (_benchmark is { HasRun: true } benchmark) _renderer?.DrawBenchmarkOverlay(benchmark);
if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode); if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode);
_benchmarkNeedsInitialFrame = false;
} }
private void UpdateNeighborhood() private void UpdateNeighborhood()
@@ -229,8 +252,8 @@ public sealed class UniverseApplication : IDisposable
if (_menuOpen) if (_menuOpen)
{ {
if (key == Key.Escape) SetMenu(false); if (key == Key.Escape) SetMenu(false);
else if (key == Key.Up) _menuSelection = (_menuSelection + 5) % 6; else if (key == Key.Up) _menuSelection = (_menuSelection + 6) % 7;
else if (key == Key.Down) _menuSelection = (_menuSelection + 1) % 6; else if (key == Key.Down) _menuSelection = (_menuSelection + 1) % 7;
else if (key is Key.Enter or Key.KeypadEnter) ActivateMenu(); else if (key is Key.Enter or Key.KeypadEnter) ActivateMenu();
return; return;
} }
@@ -273,6 +296,16 @@ public sealed class UniverseApplication : IDisposable
_camera.RequestOceanView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem)); _camera.RequestOceanView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if (key == Key.F7) else if (key == Key.F7)
_camera.RequestCloudView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem)); _camera.RequestCloudView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
else if(key==Key.F8)
{
var galaxy=_galaxies.GalaxyAt(_camera.Position);
_camera.RequestCosmicView(galaxy.Center,galaxy.RadiusMeters,galaxy.Orientation,false,_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
}
else if(key==Key.F9)
{
var nebula=_galaxies.GalaxyAt(_camera.Position).Nebulae.MinBy(n=>n.Center.RelativeTo(_camera.Position,_generator.CellSizeMeters).LengthSquared)!;
_camera.RequestCosmicView(nebula.Center,nebula.RadiusMeters,nebula.Orientation,true,_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
}
else if (key == Key.F1 && _mouse is not null) else if (key == Key.F1 && _mouse is not null)
{ {
_mouseCaptured = !_mouseCaptured; _mouseCaptured = !_mouseCaptured;
@@ -350,8 +383,16 @@ public sealed class UniverseApplication : IDisposable
private void ActivateMenu() private void ActivateMenu()
{ {
if (_menuSelection == 5) { _window.Close(); return; } if (_menuSelection == 6) { _window.Close(); return; }
if (_menuSelection == 2) { _debugMode = !_debugMode; return; } if (_menuSelection == 2) { _debugMode = !_debugMode; return; }
if (_menuSelection == 5)
{
_debugMode = false; _player = null;
_benchmark = CreateGalaxyJourney();
_benchmarkNeedsInitialFrame = true;
_camera.SetPlayerPose(_homeCameraPosition, _homeCameraForward, _homeCameraUp, resetMotionHistory: true);
UpdateNeighborhood(); SetMenu(false); return;
}
if (_menuSelection is 3 or 4) if (_menuSelection is 3 or 4)
{ {
try try
@@ -375,6 +416,7 @@ public sealed class UniverseApplication : IDisposable
private void StartBenchmark(bool unknownPlanet) private void StartBenchmark(bool unknownPlanet)
{ {
_camera.RestoreDefaultFieldOfView();
var system = _homeSystem; var system = _homeSystem;
var planet = _homeEarth; var planet = _homeEarth;
if (unknownPlanet) if (unknownPlanet)
@@ -398,12 +440,24 @@ public sealed class UniverseApplication : IDisposable
_debugMode = false; _debugMode = false;
_player = null; _player = null;
_benchmark = new(system, planet, _generator.CellSizeMeters, _camera, _benchmark = new BenchmarkRunner(system, planet, _generator.CellSizeMeters, _camera,
_homeCameraPosition, _homeCameraForward, _homeCameraUp); _homeCameraPosition, _homeCameraForward, _homeCameraUp);
_benchmarkNeedsInitialFrame = true;
UpdateNeighborhood(); UpdateNeighborhood();
SetMenu(false); SetMenu(false);
} }
private GalaxyJourneyBenchmark CreateGalaxyJourney()
{
_camera.RestoreDefaultFieldOfView();
var journey = new GalaxyJourneyBenchmark(_galaxies, _generator, _homeSystem, _homeEarth,
_homeCameraPosition, _homeCameraForward, _homeCameraUp);
journey.RenderEnvironment = new { Width = _window.FramebufferSize.X, Height = _window.FramebufferSize.Y, Gpu = _renderer?.HardwareName, Mode = "interactive-vsync", FieldOfViewDegrees = _camera.FieldOfViewRadians * 180 / System.Math.PI };
foreach (var planet in _homeSystem.Planets) _renderer?.PreparePlanet(_homeSystem, planet);
foreach (var planet in journey.TargetSystem.Planets) _renderer?.PreparePlanet(journey.TargetSystem, planet);
return journey;
}
private void OnClosing() private void OnClosing()
{ {
_renderer?.Dispose(); _renderer?.Dispose();

View File

@@ -24,6 +24,8 @@ public sealed record EngineConfiguration
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 BlackHoleGenerationSettings BlackHoles { get; init; } = new();
public GalaxySettings Galaxies { get; init; } = new();
public StarMotionBlurSettings StarMotionBlur { 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)
@@ -44,6 +46,8 @@ public sealed record EngineConfiguration
{ {
var settings = JsonSettings.Read<EngineConfiguration>(path); var settings = JsonSettings.Read<EngineConfiguration>(path);
(settings.BlackHoles ?? throw new InvalidDataException("config.json: blackHoles cannot be null.")).Validate(); (settings.BlackHoles ?? throw new InvalidDataException("config.json: blackHoles cannot be null.")).Validate();
(settings.Galaxies ?? throw new InvalidDataException("config.json: galaxies cannot be null.")).Validate();
(settings.StarMotionBlur ?? throw new InvalidDataException("config.json: starMotionBlur 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)
@@ -57,7 +61,7 @@ public sealed record EngineConfiguration
|| !double.IsFinite(startup.DistanceInRadii) || startup.DistanceInRadii < 1.05 || !double.IsFinite(startup.DistanceInRadii) || startup.DistanceInRadii < 1.05
|| !double.IsFinite(startup.LongitudeDegrees) || !double.IsFinite(startup.LatitudeDegrees) || System.Math.Abs(startup.LatitudeDegrees) > 90 || !double.IsFinite(startup.LongitudeDegrees) || !double.IsFinite(startup.LatitudeDegrees) || System.Math.Abs(startup.LatitudeDegrees) > 90
|| !double.IsFinite(startup.SpeedMetersPerSecond) || startup.SpeedMetersPerSecond < 0.1 || !double.IsFinite(startup.SpeedMetersPerSecond) || startup.SpeedMetersPerSecond < 0.1
|| startup.SpeedMetersPerSecond > UniverseScale.LightSpeedMetersPerSecond * 1_000_000 || startup.SpeedMetersPerSecond > UniverseScale.LightSpeedMetersPerSecond * 1e15
|| !double.IsFinite(startup.FieldOfViewDegrees) || startup.FieldOfViewDegrees is < 30 or > 120) || !double.IsFinite(startup.FieldOfViewDegrees) || startup.FieldOfViewDegrees is < 30 or > 120)
throw new InvalidDataException("config.json: invalid startup target, planet hotkeys, view angles, distance or flight speed. Planet hotkeys must contain at most nine non-empty planet ids."); throw new InvalidDataException("config.json: invalid startup target, planet hotkeys, view angles, distance or flight speed. Planet hotkeys must contain at most nine non-empty planet ids.");
if (string.IsNullOrWhiteSpace(settings.KnownSolarSystemsFile)) throw new InvalidDataException("config.json: knownSolarSystemsFile is required."); if (string.IsNullOrWhiteSpace(settings.KnownSolarSystemsFile)) throw new InvalidDataException("config.json: knownSolarSystemsFile is required.");

View File

@@ -65,4 +65,6 @@ public sealed record StarSystemData(
{ {
public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}"; public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}";
public string Name { get; init; } = "Proceduriellt solsystem"; public string Name { get; init; } = "Proceduriellt solsystem";
public string? GalaxyId { get; init; }
public string? NebulaId { get; init; }
} }

View File

@@ -0,0 +1,225 @@
using System.Numerics;
using Universe.Core.Configuration;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Core.Generation;
public sealed record GalaxySettings
{
public string KnownGalaxiesFile { get; init; } = "KnownGalaxies.json";
public double RegionSizeLightYears { get; init; } = 1_000_000;
public double SystemSpacingLightYears { get; init; } = 4;
public int GalaxyStarBudget { get; init; } = 6000;
public int LocalStarBudget { get; init; } = 2600;
public int VolumeSteps { get; init; } = 40;
public double MinimumBenchmarkFps { get; init; } = 45;
public double MaximumBenchmarkFrameMilliseconds { get; init; } = 50;
public void Validate()
{
if (!double.IsFinite(RegionSizeLightYears) || RegionSizeLightYears is < 300_000 or > 100_000_000
|| !double.IsFinite(SystemSpacingLightYears) || SystemSpacingLightYears is < .01 or > 100
|| GalaxyStarBudget is < 256 or > 30000 || LocalStarBudget is < 128 or > 10000
|| VolumeSteps is < 16 or > 96 || !double.IsFinite(MinimumBenchmarkFps) || MinimumBenchmarkFps is < 1 or > 1000
|| !double.IsFinite(MaximumBenchmarkFrameMilliseconds) || MaximumBenchmarkFrameMilliseconds is < 1 or > 1000)
throw new InvalidDataException("Invalid galaxies settings in config.json.");
}
}
public sealed record NebulaDefinition
{
public string Id { get; init; } = "nebula";
public double[] PositionLightYears { get; init; } = [26000, 0, 300];
public double RadiusLightYears { get; init; } = 80;
public string? Texture { get; init; }
}
public sealed record GalaxyDefinition
{
public string Id { get; init; } = "milky-way";
public string Name { get; init; } = "Vintergatan";
public long[] Region { get; init; } = [0, 0, 0];
public double[] CenterOffsetLightYears { get; init; } = [-26000, 0, 0];
public double RadiusLightYears { get; init; } = 50000;
public double ThicknessLightYears { get; init; } = 1800;
public double AxialTiltDegrees { get; init; }
public double RotationDegrees { get; init; }
public int SpiralArms { get; init; } = 4;
public string? Texture { get; init; }
public double DensityGamma { get; init; } = .7;
public NebulaDefinition[] Nebulae { get; init; } = [];
}
public sealed record KnownGalaxyDefinitions
{
public GalaxyDefinition[] Galaxies { get; init; } = [];
}
public sealed record NebulaData(string Id, UniversePosition Center, double RadiusMeters, PlanetOrientation Orientation, ulong Seed, RasterImage? Texture);
public sealed record GalaxyData(string Id, string Name, GalaxyCoordinate Region, UniversePosition Center, double RadiusMeters,
double ThicknessMeters, PlanetOrientation Orientation, int SpiralArms, ulong Seed, double DensityGamma, RasterImage? Texture,
IReadOnlyList<NebulaData> Nebulae)
{
public double PlanarDensity(double x, double z)
{
var r = System.Math.Sqrt(x * x + z * z);
if (r >= 1) return 0;
double density;
if (Texture is { } map)
{
var u = System.Math.Clamp((x * .5 + .5) * (map.Width - 1), 0, map.Width - 1);
var v = System.Math.Clamp((.5 - z * .5) * (map.Height - 1), 0, map.Height - 1);
var ix = (int)u; var iy = (int)v;
double At(int px, int py)
{
var p = (py * map.Width + px) * 4;
return (map.Rgba[p] * .2126 + map.Rgba[p + 1] * .7152 + map.Rgba[p + 2] * .0722) / 255 * map.Rgba[p + 3] / 255;
}
var tx = u - ix; var ty = v - iy;
var a = At(ix, iy) * (1 - tx) + At(System.Math.Min(ix + 1, map.Width - 1), iy) * tx;
var b = At(ix, System.Math.Min(iy + 1, map.Height - 1)) * (1 - tx)
+ At(System.Math.Min(ix + 1, map.Width - 1), System.Math.Min(iy + 1, map.Height - 1)) * tx;
density = a * (1 - ty) + b * ty;
}
else
{
var spiral = System.Math.Atan2(z, x) * SpiralArms - System.Math.Log(r + .05) * 5.8;
var arms = System.Math.Pow(.5 + .5 * System.Math.Cos(spiral), 5);
density = (.05 + arms * .8) * System.Math.Pow(1 - r, .7) + System.Math.Exp(-r * r * 65) * .8;
}
return System.Math.Pow(System.Math.Clamp(density, 0, 1), DensityGamma);
}
public double Density(UniversePosition position, double cellSize)
{
var p = Orientation.ToBody(position.RelativeTo(Center, cellSize));
var vertical = System.Math.Exp(-System.Math.Abs(p.Y) / ThicknessMeters);
return vertical < .001 ? 0 : PlanarDensity(p.X / RadiusMeters, p.Z / RadiusMeters) * vertical;
}
}
/// <summary>Procedural macrocells and sparse deterministic stellar candidates; textures control real occupancy.</summary>
public sealed class GalaxyUniverse
{
private readonly Dictionary<GalaxyCoordinate, GalaxyData> _cache = [];
private readonly Dictionary<GalaxyCoordinate, GalaxyDefinition> _known;
private readonly string _root;
public ulong Seed { get; }
public double CellSize { get; }
public GalaxySettings Settings { get; }
public long RegionCells { get; }
public long SpacingCells { get; }
public GalaxyUniverse(ulong seed, double cellSize, GalaxySettings settings, string catalogPath)
{
settings.Validate(); Seed = seed; CellSize = cellSize; Settings = settings;
RegionCells = checked((long)System.Math.Round(settings.RegionSizeLightYears * UniverseScale.LightYearMeters / cellSize));
SpacingCells = System.Math.Max(2, checked((long)System.Math.Round(settings.SystemSpacingLightYears * UniverseScale.LightYearMeters / cellSize)));
_root = Path.GetDirectoryName(Path.GetFullPath(catalogPath))!;
_known = new();
foreach (var definition in JsonSettings.Read<KnownGalaxyDefinitions>(catalogPath).Galaxies)
{
if (definition.Region.Length != 3 || definition.CenterOffsetLightYears.Length != 3 || string.IsNullOrWhiteSpace(definition.Id)
|| !double.IsFinite(definition.RadiusLightYears) || definition.RadiusLightYears <= 0 || definition.RadiusLightYears > settings.RegionSizeLightYears * .2
|| !double.IsFinite(definition.ThicknessLightYears) || definition.ThicknessLightYears <= 0
|| !double.IsFinite(definition.DensityGamma) || definition.DensityGamma is < .1 or > 8
|| definition.SpiralArms is < 1 or > 8 || definition.CenterOffsetLightYears.Any(v => !double.IsFinite(v)))
throw new InvalidDataException("Invalid galaxy in KnownGalaxies.json.");
_known.Add(new(definition.Region[0], definition.Region[1], definition.Region[2]), definition);
}
}
public GalaxyCoordinate RegionAt(UniversePosition position) => GalaxyAddress.FromPosition(position, RegionCells).Region;
public GalaxyData GalaxyAt(UniversePosition position) => Get(RegionAt(position));
public GalaxyData Get(GalaxyCoordinate coordinate)
{
if (_cache.TryGetValue(coordinate, out var cached)) return cached;
var seed = Hash(new(coordinate.X, coordinate.Y, coordinate.Z), Seed ^ 0x47414C415859UL);
var random = new DeterministicRandom(seed);
_known.TryGetValue(coordinate, out var known);
var regionOrigin = new GalaxyAddress(coordinate, new(0, 0, 0), DVector3.Zero).ToPosition(RegionCells);
var offset = known is null ? new DVector3(random.NextDouble() - .5, random.NextDouble() - .5, random.NextDouble() - .5) * (Settings.RegionSizeLightYears * .15)
: new(known.CenterOffsetLightYears[0], known.CenterOffsetLightYears[1], known.CenterOffsetLightYears[2]);
var center = regionOrigin.Add(offset * UniverseScale.LightYearMeters, CellSize);
var radius = (known?.RadiusLightYears ?? (25000 + random.NextDouble() * 45000)) * UniverseScale.LightYearMeters;
var thickness = (known?.ThicknessLightYears ?? (800 + random.NextDouble() * 1800)) * UniverseScale.LightYearMeters;
var orientation = new PlanetOrientation(known?.AxialTiltDegrees ?? random.NextDouble() * 130 - 65, known?.RotationDegrees ?? random.NextDouble() * 360);
var id = known?.Id ?? $"galaxy:{coordinate.X}:{coordinate.Y}:{coordinate.Z}";
var nebulae = new List<NebulaData>();
foreach (var n in known?.Nebulae ?? [])
{
if (n.PositionLightYears.Length != 3 || n.PositionLightYears.Any(v => !double.IsFinite(v)) || !double.IsFinite(n.RadiusLightYears) || n.RadiusLightYears <= 0)
throw new InvalidDataException("Invalid nebula in KnownGalaxies.json.");
var local = new DVector3(n.PositionLightYears[0], n.PositionLightYears[1], n.PositionLightYears[2]) * UniverseScale.LightYearMeters;
nebulae.Add(new(n.Id, center.Add(orientation.ToWorld(local), CellSize), n.RadiusLightYears * UniverseScale.LightYearMeters,
orientation, seed ^ (ulong)nebulae.Count, Load(n.Texture)));
}
for (var i = 0; i < 8; i++)
{
var r = .18 + random.NextDouble() * .6; var phi = random.NextDouble() * System.Math.Tau;
var local = new DVector3(System.Math.Cos(phi) * r * radius, (random.NextDouble() - .5) * thickness, System.Math.Sin(phi) * r * radius);
nebulae.Add(new($"{id}:nebula:{i}", center.Add(orientation.ToWorld(local), CellSize), (150 + random.NextDouble() * 700) * UniverseScale.LightYearMeters,
new(random.NextDouble() * 180, random.NextDouble() * 360), random.NextUInt64(), null));
}
var galaxy = new GalaxyData(id, known?.Name ?? $"Galax {coordinate.X}:{coordinate.Y}:{coordinate.Z}", coordinate, center, radius, thickness, orientation,
known?.SpiralArms ?? random.NextInt(2, 6), seed, known?.DensityGamma ?? .7, Load(known?.Texture), nebulae);
if (_cache.Count >= 96)
{
var victim = _cache.Keys.FirstOrDefault(k => !_known.ContainsKey(k));
_cache.Remove(victim);
}
_cache[coordinate] = galaxy;
return galaxy;
}
private RasterImage? Load(string? path) => path is null ? null : RasterImage.Load(Path.GetFullPath(Path.Combine(_root, path)));
public IReadOnlyList<GalaxyData> VisibleGalaxies(UniversePosition position)
{
var r = RegionAt(position); var result = new List<GalaxyData>(27);
for (var z = -1; z <= 1; z++) for (var y = -1; y <= 1; y++) for (var x = -1; x <= 1; x++)
{
try { result.Add(Get(new(checked(r.X + x), checked(r.Y + y), checked(r.Z + z)))); }
catch (OverflowException) { }
}
return result;
}
public static long FloorDiv(long a, long b) { var q = a / b; return a < 0 && a % b != 0 ? q - 1 : q; }
public SectorCoordinate StellarBlock(SectorCoordinate sector) => new(FloorDiv(sector.X, SpacingCells), FloorDiv(sector.Y, SpacingCells), FloorDiv(sector.Z, SpacingCells));
public bool Candidate(GalaxyData galaxy, SectorCoordinate block, out SectorCoordinate sector)
{
var random = new DeterministicRandom(Hash(block, Seed ^ 0x5354415253UL));
long Offset() => (long)((.12 + random.NextDouble() * .76) * SpacingCells);
sector = new(checked(block.X * SpacingCells + Offset()), checked(block.Y * SpacingCells + Offset()), checked(block.Z * SpacingCells + Offset()));
return random.NextDouble() < galaxy.Density(UniversePosition.AtCellCenter(sector, CellSize), CellSize);
}
public bool HasSystem(SectorCoordinate sector)
{
var position = UniversePosition.AtCellCenter(sector, CellSize);
return Candidate(GalaxyAt(position), StellarBlock(sector), out var candidate) && candidate == sector;
}
public IReadOnlyList<SectorCoordinate> NearbySystemCells(SectorCoordinate sector, int radius = 3, int maximum = 16)
{
var center = UniversePosition.AtCellCenter(sector, CellSize); var galaxy = GalaxyAt(center); var block = StellarBlock(sector);
var found = new List<SectorCoordinate>();
for (var z = -radius; z <= radius; z++) for (var y = -radius; y <= radius; y++) for (var x = -radius; x <= radius; x++)
if (Candidate(galaxy, new(block.X + x, block.Y + y, block.Z + z), out var c)) found.Add(c);
// Sol is pinned and never rejected by the density map.
if (galaxy.Id == "milky-way" && center.RelativeTo(UniversePosition.AtCellCenter(new(), CellSize), CellSize).Length < SpacingCells * CellSize * (radius + 1)) found.Add(new());
return found.Distinct().OrderBy(c => UniversePosition.AtCellCenter(c, CellSize).RelativeTo(center, CellSize).LengthSquared).Take(maximum).ToArray();
}
public SectorCoordinate SelectSystem(GalaxyData galaxy)
{
var random = new DeterministicRandom(galaxy.Seed ^ 0x544152474554UL);
for (var i = 0; i < 4096; i++)
{
var local = new DVector3(random.NextDouble() * 1.4 - .7, (random.NextDouble() - .5) * galaxy.ThicknessMeters / galaxy.RadiusMeters,
random.NextDouble() * 1.4 - .7) * galaxy.RadiusMeters;
var block = StellarBlock(galaxy.Center.Add(galaxy.Orientation.ToWorld(local), CellSize).Sector);
if (Candidate(galaxy, block, out var sector)) return sector;
}
throw new InvalidOperationException("Galaxy density contains no selectable system. Check the texture brightness and alpha.");
}
public string NebulaFor(GalaxyData galaxy, UniversePosition position) => galaxy.Nebulae.MinBy(n => position.RelativeTo(n.Center, CellSize).LengthSquared)!.Id;
public static ulong Hash(SectorCoordinate p, ulong seed) => DeterministicRandom.Mix(DeterministicRandom.Mix(DeterministicRandom.Mix(seed ^ unchecked((ulong)p.X)) ^ unchecked((ulong)p.Y)) ^ unchecked((ulong)p.Z));
}

View File

@@ -0,0 +1,23 @@
namespace Universe.Core.Generation;
public sealed record StarMotionBlurSettings
{
public bool Enabled { get; init; } = true;
public double ShutterFraction { get; init; } = .9;
public double MaximumTrailPixels { get; init; } = 180;
public double MinimumWidthPixels { get; init; } = 1.4;
public double MovingBrightness { get; init; } = 1.8;
public double HistoryResetSeconds { get; init; } = .25;
public double StarFadeSeconds { get; init; } = .3;
public void Validate()
{
if (!double.IsFinite(ShutterFraction) || ShutterFraction is < 0 or > 2
|| !double.IsFinite(MaximumTrailPixels) || MaximumTrailPixels is < 1 or > 1024
|| !double.IsFinite(MinimumWidthPixels) || MinimumWidthPixels is < .5 or > 6
|| !double.IsFinite(MovingBrightness) || MovingBrightness is < 1 or > 4
|| !double.IsFinite(HistoryResetSeconds) || HistoryResetSeconds is < .05 or > 2
|| !double.IsFinite(StarFadeSeconds) || StarFadeSeconds is < .05 or > 2)
throw new InvalidDataException("config.json: invalid starMotionBlur settings.");
}
}

View File

@@ -16,7 +16,7 @@ public sealed class UniverseGenerator
[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) BlackHoleGenerationSettings? blackHoles = null, GalaxyUniverse? galaxies = null)
{ {
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0) if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0)
throw new ArgumentOutOfRangeException(nameof(cellSizeMeters)); throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
@@ -25,15 +25,26 @@ public sealed class UniverseGenerator
_knownSystems = knownSystems; _knownSystems = knownSystems;
BlackHoles = blackHoles ?? new(); BlackHoles = blackHoles ?? new();
BlackHoles.Validate(); BlackHoles.Validate();
Galaxies = galaxies;
} }
public ulong UniverseSeed { get; } public ulong UniverseSeed { get; }
public double CellSizeMeters { get; } public double CellSizeMeters { get; }
public BlackHoleGenerationSettings BlackHoles { get; } public BlackHoleGenerationSettings BlackHoles { get; }
public GalaxyUniverse? Galaxies { get; }
public StarSystemData Generate(SectorCoordinate coordinate) public StarSystemData Generate(SectorCoordinate coordinate)
{ {
if (_knownSystems is not null && _knownSystems.TryGet(coordinate, out var known)) return known; StarSystemData Assign(StarSystemData s)
{
if (Galaxies is null) return s;
var position = UniversePosition.AtCellCenter(coordinate, CellSizeMeters);
var galaxy = Galaxies.GalaxyAt(position);
return s with { GalaxyId = galaxy.Id, NebulaId = Galaxies.NebulaFor(galaxy, position) };
}
if (_knownSystems is not null && _knownSystems.TryGet(coordinate, out var known)) return Assign(known);
if (Galaxies is not null && !Galaxies.HasSystem(coordinate))
return new(coordinate, SeedFor(coordinate), [], []) { Name = "Interstellar space" };
var systemSeed = SeedFor(coordinate); var systemSeed = SeedFor(coordinate);
var random = new DeterministicRandom(systemSeed); var random = new DeterministicRandom(systemSeed);
var noiseOffset = ProceduralNoise.SeedOffset(systemSeed); var noiseOffset = ProceduralNoise.SeedOffset(systemSeed);
@@ -125,7 +136,7 @@ public sealed class UniverseGenerator
}); });
moonCount++; moonCount++;
} }
return new(coordinate, systemSeed, stars, planets); return Assign(new(coordinate, systemSeed, stars, planets));
} }
public IEnumerable<StarSystemData> GenerateNeighborhood(SectorCoordinate center, int radius = 1) public IEnumerable<StarSystemData> GenerateNeighborhood(SectorCoordinate center, int radius = 1)

View File

@@ -0,0 +1,28 @@
using Universe.Core.Math;
namespace Universe.Core.World;
public readonly record struct GalaxyCoordinate(long X, long Y, long Z);
/// <summary>Integer macrocell, integer solar-system cell, then local metres. No huge global float position.</summary>
public readonly record struct GalaxyAddress(GalaxyCoordinate Region, SectorCoordinate SystemCell, DVector3 LocalMeters)
{
public static GalaxyAddress FromPosition(UniversePosition position, long regionCells)
{
static (long Region, long Local) Split(long value, long size)
{
var wide = (Int128)value + size / 2;
var region = wide / size;
if (wide < 0 && wide % size != 0) region--;
return (checked((long)region), checked((long)((Int128)value - region * size)));
}
if (regionCells < 2) throw new ArgumentOutOfRangeException(nameof(regionCells));
var x = Split(position.Sector.X, regionCells); var y = Split(position.Sector.Y, regionCells); var z = Split(position.Sector.Z, regionCells);
return new(new(x.Region, y.Region, z.Region), new(x.Local, y.Local, z.Local), position.LocalOffset);
}
public UniversePosition ToPosition(long regionCells) => new(new(
checked((long)((Int128)Region.X * regionCells + SystemCell.X)),
checked((long)((Int128)Region.Y * regionCells + SystemCell.Y)),
checked((long)((Int128)Region.Z * regionCells + SystemCell.Z))), LocalMeters);
}

View File

@@ -4,6 +4,7 @@ public static class UniverseScale
{ {
public const double AstronomicalUnitMeters = 149_597_870_700d; public const double AstronomicalUnitMeters = 149_597_870_700d;
public const double LightSpeedMetersPerSecond = 299_792_458d; public const double LightSpeedMetersPerSecond = 299_792_458d;
public const double LightYearMeters = 9_460_730_472_580_800d;
public const double SolarRadiusMeters = 695_700_000d; public const double SolarRadiusMeters = 695_700_000d;
public const double EarthRadiusMeters = 6_371_000d; public const double EarthRadiusMeters = 6_371_000d;
public const double DefaultCellSizeMeters = 100d * AstronomicalUnitMeters; public const double DefaultCellSizeMeters = 100d * AstronomicalUnitMeters;

View File

@@ -8,10 +8,20 @@ public sealed record UniverseNeighborhood(StarSystemData ActiveSystem, IReadOnly
public sealed class UniverseStreamer(UniverseGenerator generator) public sealed class UniverseStreamer(UniverseGenerator generator)
{ {
private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = []; private readonly Dictionary<SectorCoordinate, StarSystemData> _cache = [];
private SectorCoordinate? _nearBlock;
private IReadOnlyList<StarSystemData> _nearSystems = [];
public (StarSystemData System, StarData Hole)? FindNearestBlackHole(UniversePosition position) public (StarSystemData System, StarData Hole)? FindNearestBlackHole(UniversePosition position)
{ {
if (generator.BlackHoles.OccurrenceChance <= 0) return null; if (generator.BlackHoles.OccurrenceChance <= 0) return null;
if (generator.Galaxies is { } galaxies)
{
return galaxies.NearbySystemCells(position.Sector, 8, 2048).Select(GetOrCreate)
.SelectMany(s => s.Stars.Where(h => h.IsBlackHole).Select(h => ((StarSystemData System, StarData Hole)?)(s, h)))
.OrderBy(b => UniversePosition.AtCellCenter(b!.Value.System.Coordinate, generator.CellSizeMeters)
.Add(b.Value.Hole.PositionMeters, generator.CellSizeMeters).RelativeTo(position, generator.CellSizeMeters).LengthSquared)
.FirstOrDefault();
}
(StarSystemData System, StarData Hole)? nearest = null; (StarSystemData System, StarData Hole)? nearest = null;
var bestDistance = double.PositiveInfinity; var bestDistance = double.PositiveInfinity;
var cell = generator.CellSizeMeters; var cell = generator.CellSizeMeters;
@@ -44,6 +54,21 @@ public sealed class UniverseStreamer(UniverseGenerator generator)
public UniverseNeighborhood Load(SectorCoordinate activeCoordinate) public UniverseNeighborhood Load(SectorCoordinate activeCoordinate)
{ {
var active = GetOrCreate(activeCoordinate); var active = GetOrCreate(activeCoordinate);
if (generator.Galaxies is { } galaxies)
{
var block = galaxies.StellarBlock(activeCoordinate);
if (_nearBlock != block)
{
_nearSystems = galaxies.NearbySystemCells(activeCoordinate).Select(GetOrCreate).ToArray();
_nearBlock = block;
}
if (_cache.Count > 96)
{
var keep = _nearSystems.Select(s => s.Coordinate).Append(activeCoordinate).ToHashSet();
foreach (var key in _cache.Keys.ToArray()) if (!keep.Contains(key)) _cache.Remove(key);
}
return new(active, _nearSystems.Where(s => s.Coordinate != activeCoordinate).ToArray());
}
var nearby = new List<StarSystemData>(26); var nearby = new List<StarSystemData>(26);
for (var z = -1; z <= 1; z++) for (var z = -1; z <= 1; z++)
for (var y = -1; y <= 1; y++) for (var y = -1; y <= 1; y++)
@@ -71,6 +96,7 @@ public sealed class UniverseStreamer(UniverseGenerator generator)
if (!_cache.TryGetValue(coordinate, out var system)) if (!_cache.TryGetValue(coordinate, out var system))
{ {
system = generator.Generate(coordinate); system = generator.Generate(coordinate);
if (system.Stars.Count == 0) return system;
_cache.Add(coordinate, system); _cache.Add(coordinate, system);
} }
return system; return system;

View File

@@ -0,0 +1,84 @@
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Tests;
public sealed class GalaxyTests
{
private static GalaxyUniverse Create() => new(0x554E495645525345UL,UniverseScale.DefaultCellSizeMeters,new(),Path.Combine(AppContext.BaseDirectory,"KnownGalaxies.json"));
[Fact]
public void HierarchicalAddressKeepsLocalMetresAtRemoteGalaxyCoordinates()
{
var universe=Create();
var address=new GalaxyAddress(new(1_000_000_000,-1_000_000_000,500),new(-53,21,700),new(456.75,901.125,18));
var position=address.ToPosition(universe.RegionCells);
Assert.Equal(address,GalaxyAddress.FromPosition(position,universe.RegionCells));
var moved=position.Add(new(.125,.25,.5),universe.CellSize);
Assert.Equal(new DVector3(.125,.25,.5),moved.RelativeTo(position,universe.CellSize));
var negative=new UniversePosition(new(-universe.RegionCells/2-1,0,0),DVector3.Zero);
Assert.Equal(-1,GalaxyAddress.FromPosition(negative,universe.RegionCells).Region.X);
}
[Fact]
public void TextureBrightnessAndAlphaControlActualSystemOccupancy()
{
var universe=Create();var galaxy=universe.Get(new());
var white=new RasterImage(2,2,Enumerable.Repeat((byte)255,16).ToArray());
var transparent=white.Rgba.ToArray();for(var i=3;i<16;i+=4)transparent[i]=0;
var bright=galaxy with {Texture=white,Center=UniversePosition.AtCellCenter(new(),universe.CellSize)};
var dark=bright with {Texture=new(2,2,new byte[16])};
var alpha=bright with {Texture=new(2,2,transparent)};
Assert.Equal(1,bright.PlanarDensity(0,0),10);
Assert.Equal(0,dark.PlanarDensity(0,0));Assert.Equal(0,alpha.PlanarDensity(0,0));
var count=0;
for(var x=-3;x<=3;x++)for(var z=-3;z<=3;z++)
{
if(universe.Candidate(bright,new(x,0,z),out _))count++;
Assert.False(universe.Candidate(dark,new(x,0,z),out _));
Assert.False(universe.Candidate(alpha,new(x,0,z),out _));
}
Assert.True(count>20);
}
[Fact]
public void GeneratedGalaxiesAreDeterministicAndSystemsHaveParents()
{
var a=Create();var b=Create();var coordinate=new GalaxyCoordinate(1,0,0);
var first=a.Get(coordinate);var second=b.Get(coordinate);
Assert.Equal(first.Center,second.Center);Assert.Equal(first.Seed,second.Seed);
var sector=a.SelectSystem(first);Assert.Equal(sector,b.SelectSystem(second));
var generator=new UniverseGenerator(a.Seed,galaxies:a);
var system=generator.Generate(sector);
Assert.NotEmpty(system.Stars);Assert.NotEmpty(system.Planets);
Assert.Equal(first.Id,system.GalaxyId);Assert.NotNull(system.NebulaId);
Assert.Empty(generator.Generate(first.Center.Add(new(first.RadiusMeters*2,0,0),a.CellSize).Sector).Stars);
}
[Fact]
public void JourneyVisitsBothGalaxiesLandsAtEyeHeightAndDetectsFrameSpikes()
{
var galaxies=Create();
var home=new UniverseGenerator(123).Generate(new());
var earth=home.Planets[0] with {Id="earth"};home=home with {Planets=[earth]};
var body=UniversePosition.AtCellCenter(home.Coordinate,galaxies.CellSize).Add(earth.PositionMeters,galaxies.CellSize);
var radial=new DVector3(1,.2,.1).Normalized();var start=body.Add(radial*earth.RadiusMeters*3,galaxies.CellSize);
var generator=new UniverseGenerator(galaxies.Seed,galaxies:galaxies);
var journey=new GalaxyJourneyBenchmark(galaxies,generator,home,earth,start,-radial,new(0,1,0));
var camera=new FlyCamera(start,galaxies.CellSize);var streamer=new UniverseStreamer(generator);var visitedTarget=false;
while(journey.IsRunning)
{
journey.Update(.25,camera);
var loaded=streamer.Load(camera.Position.Sector);
if(loaded.ActiveSystem.Coordinate==journey.TargetSystem.Coordinate && loaded.ActiveSystem.Planets.Count>0)visitedTarget=true;
if(journey.Phase=="GROUND WALK")
{
var targetBody=UniversePosition.AtCellCenter(journey.TargetSystem.Coordinate,galaxies.CellSize).Add(journey.TargetPlanet.PositionMeters,galaxies.CellSize);
var delta=camera.Position.RelativeTo(targetBody,galaxies.CellSize);
Assert.InRange(delta.Length-TerrainHeight.Sample(journey.TargetPlanet,delta),1.79,1.81);
}
journey.RecordFrame(journey.ElapsedSeconds is >30 and <31?.1:.01);
}
Assert.True(visitedTarget);Assert.Equal(7,journey.VisitedPhases.Count);Assert.Equal(start,camera.Position);
Assert.False(journey.PerformancePassed);
Assert.Contains(journey.GraphSamples,fps=>fps<20);
}
}

View File

@@ -0,0 +1,42 @@
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.Tests;
public sealed class StarMotionTests
{
[Fact]
public void HistoryRetainsSmallMovementAtLargeCoordinatesAndResetsOnCameraCuts()
{
var cell=UniverseScale.DefaultCellSizeMeters;
var position=UniversePosition.AtCellCenter(new(600_000_000_000_000_000,-500,200),cell);
var camera=new FlyCamera(position,cell);
var history=new StarMotionHistory();var settings=new StarMotionBlurSettings();
history.Advance(camera,1440,900,1d/60,settings);
Assert.False(history.Valid);
camera.SetPlayerPose(position.Add(new(.125,.25,.5),cell),camera.Forward,camera.Up);
history.Advance(camera,1440,900,1d/60,settings);
Assert.True(history.Valid);
Assert.Equal(new DVector3(.125,.25,.5),history.Current.Position.RelativeTo(history.Previous.Position,cell));
camera.SetPlayerPose(position.Add(new(1e20,0,0),cell),camera.Forward,camera.Up,resetMotionHistory:true);
history.Advance(camera,1440,900,1d/60,settings);
Assert.False(history.Valid);
Assert.Equal(history.Current,history.Previous);
}
[Fact]
public void PauseResizeAndLongFramesCannotSmearUnrelatedFramesTogether()
{
var camera=new FlyCamera(UniversePosition.AtCellCenter(new(),UniverseScale.DefaultCellSizeMeters),UniverseScale.DefaultCellSizeMeters);
var history=new StarMotionHistory();var settings=new StarMotionBlurSettings();
history.Advance(camera,1440,900,1d/60,settings);
history.Advance(camera,1440,900,1d/60,settings);Assert.True(history.Valid);
history.Advance(camera,1920,1080,1d/60,settings);Assert.False(history.Valid);
history.Advance(camera,1920,1080,0,settings);Assert.False(history.Valid);
history.Advance(camera,1920,1080,.5,settings);Assert.False(history.Valid);
history.Advance(camera,1920,1080,1d/60,settings);Assert.True(history.Valid);
history.Advance(camera,1920,1080,1d/60,settings with {Enabled=false});Assert.False(history.Valid);
}
}

View File

@@ -21,6 +21,9 @@
<ProjectReference Include="..\..\src\Universe.Core\Universe.Core.csproj" /> <ProjectReference Include="..\..\src\Universe.Core\Universe.Core.csproj" />
<ProjectReference Include="..\..\src\Universe.App\Universe.App.csproj" /> <ProjectReference Include="..\..\src\Universe.App\Universe.App.csproj" />
<Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\KnownGalaxies.json" Link="KnownGalaxies.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\Content\Galaxies\*" Link="Content\Galaxies\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\Content\Nebulae\*" Link="Content\Nebulae\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>