diff --git a/KnownSolarSystem/README.md b/KnownSolarSystem/README.md index d990cc9..1151dbf 100644 --- a/KnownSolarSystem/README.md +++ b/KnownSolarSystem/README.md @@ -47,13 +47,41 @@ Tryck **Q** för att flyga mjukt till den närmaste planeten från kamerans nuva - `systems`: lägg till fler system med eget `id`, `name`, `sector`, `centerOffsetMeters`, `stars` och `planets`. Två kända system får inte ligga i samma cell. - `stars`: `id`, `name`, `positionMeters`, `radiusMeters`, `luminositySolar`, linjär RGB-`color` och valfri `seed`. En eller två stjärnor stöds. - `planets`: `id`, `name`, `description`, `hostStarId`, `radiusMeters`, `seed`, `axialTiltDegrees`, `rotationDegrees`, `orbit`, `surfaceType`, `surfaceColor`, valfri `surfaceTexture`, `terrain`, `atmosphere`, valfria `clouds`, `ocean`, `rings` och `moons`. -- `orbit`: `semiMajorAxisAu`, `eccentricity`, `phaseDegrees`, `inclinationDegrees`, `ascendingNodeDegrees`, `periapsisDegrees`. Banorna är statiska elliptiska ögonblicksbilder. En satellits bana är relativ till moderplanetens centrum. +- `orbit`: `semiMajorAxisAu`, `eccentricity`, `meanAnomalyDegrees`, `inclinationDegrees`, `ascendingNodeDegrees`, `periapsisDegrees`, valfri `periodDays`. Banorna beräknas analytiskt från systemets `epochUtc`. En satellits bana är relativ till den rörliga moderplanetens centrum. Äldre `phaseDegrees` stöds som sann anomali vid epoken om `meanAnomalyDegrees` saknas. - `surfaceType`: `rocky`, `terrestrial`, `cloudCovered`, `gasGiant` eller `iceGiant`. Gas- och isjättarna har molnband, inte berg. `cloudBandFrequency` och `stormStrength` styr bandtäthet och en större storm. - `atmosphere`: `heightMeters`, `density`, RGB-`color`, `composition`. Sammansättningen är beskrivande metadata; `density`, höjd och färg styr grafiken. - `rings`: `innerRadiusMeters`, `outerRadiusMeters`, RGB-`color`, `opacity` samt valfria `gapInnerRadiusMeters` och `gapOuterRadiusMeters`. Ringarnas plan följer planetens axellutning och skuggar planeten. Alla längder anges i **meter**, utom banornas halvstoraxel som anges i **AU**. Färger har tre komponenter i intervallet 0–1. `seed` är ett heltal; utelämnad seed härleds stabilt från id. Felaktiga id:n, saknade kartor, orimliga värden och objekt utanför sin gridcell ger ett tydligt startfel. +## Tid, banor och rotation + +Varje system har `epochUtc` (standard `2000-01-01T12:00:00Z`). Banfaser och rotationsfaser är relativa till denna tidpunkt. `simulation.startUtc` och `simulation.timeScale` i projektets `config.json` väljer startdatum och tidsfaktor; standard är 1× verklig tid. Menyn **P → DEBUG ON** har ett tidsreglage 0–100×. Menyn och benchmarkrutterna pausar världstiden, och 0× pausar bara simulationen, så att kameran fortfarande kan användas. + +| JSON-fält | Betydelse | +| --- | --- | +| Stjärnans `massKg` | Massa i kg; används för beräkning av omloppstid om perioden saknas. Standard en solmassa. | +| Planetens `massKg` | Massa i kg; används för tvåkroppsperiod och satellitbanor. Standard en jordmassa. | +| `orbit.semiMajorAxisAu` | Ellipsens halvstoraxel i AU. | +| `orbit.eccentricity` | Excentricitet från 0 till mindre än 1. | +| `orbit.periodDays` | Siderisk omloppstid i dygn om 86400 sekunder; om utelämnad används `2π √(a³ / G(M+m))`. | +| `orbit.meanAnomalyDegrees` | Medelanomali vid epoken. Har företräde framför äldre `phaseDegrees` (sann anomali). | +| `orbit.inclinationDegrees` | Banplanets lutning mot systemets XZ-plan. | +| `orbit.ascendingNodeDegrees` | Uppstigande nodens longitud. | +| `orbit.periapsisDegrees` | Periapsisargument relativt noden, inte periheliets absoluta longitud. | +| `rotationPeriodHours` | Siderisk axelrotation i timmar. Standard 24. Måste vara ändlig och skild från noll. | +| `axialTiltDegrees` | Axellutning mot systemets +Y-riktning, 0–180 grader. Värden över 90 ger retrograd rotation med positiv rotationsperiod. | +| `rotationDegrees` | Meridianens rotationsfas vid epoken. | +| `poleLongitudeDegrees` | Vridning av den lutade axelns riktning runt systemets Y-axel; standard 0. | + +`rotationPeriodHours` anges som positivt belopp i vårt solsystem. Venus (177,4°) och Uranus (97,8°) har redan omvänd rotationsriktning genom axellutningen; en negativ period skulle vända dem en gång till. Negativa perioder stöds för andra konfigurationer som vill vända spinnen kring den angivna axeln. Jorden använder en siderisk dag på cirka 23 h 56 min, medan dess soldygn blir ungefär 24 timmar genom kombinationen av rotation och omlopp. + +Solsystemets åtta planeter använder [JPL:s banelement vid J2000](https://ssd.jpl.nasa.gov/planets/approx_pos.html) och [sideriska perioder och massor](https://ssd.jpl.nasa.gov/planets/phys_par.html). Månen följer jorden med ungefär 27,32 dygns omlopps- och rotationstid. Proceduriella planeter får deterministiska rotationstider och omloppstider beräknade från avstånd och värdkroppens massa. Deras stjärnor har fasta lägen; dubbelstjärnornas inbördes banor integreras inte. + +Modellen är en spelbar tvåkroppsapproximation med fasta banelement, inte JPL Horizons eller en fullständig N-kroppssimulering. Sekulära störningar, precession/nutation, tidvattenkrafter, relativitet och ljusets gångtid ingår inte. Jordbanan använder jord–måne-barycentrums element som approximation. UTC används direkt för epoken utan TT/TDB-korrigering. Axelfaser och polernas longituder är visuellt konfigurerade och inte IAU-kalibrerade; atmosfärens vind kan dessutom röra molnen relativt ytan. Detta gör att perioder och omloppshastigheter är astronomiskt grundade utan att ange exakta observerade lägen för ett datum. + +Meshdata, terräng-LOD och material behåller planetens lokala koordinater. Rendering och kollision använder samma aktuella rotation utan att generera om terräng för varje rotationssteg. En markbunden spelare följer både omlopp och rotation, medan tangentbordsstyrning och hopp använder vanlig, oskalad bildrutetid. Fokusanimeringar följer rörliga planeter. `--simulation-report` genererar bilder av jordrotation, markvy och debugreglage i appens `graphics-report/`. + ## Jordens heightmap och noise Jordens inställningar kombinerar kartan med finare terräng: diff --git a/KnownSolarSystem/solar-systems.json b/KnownSolarSystem/solar-systems.json index 19964e8..d29aa06 100644 --- a/KnownSolarSystem/solar-systems.json +++ b/KnownSolarSystem/solar-systems.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "source": "https://nssdc.gsfc.nasa.gov/planetary/factsheet/ ; https://solarsystem.nasa.gov/planet-compare/", - "notes": "Avrundade planetdata i SI-enheter och AU. Sfariska radier och statiska illustrativa banfaser, inte efemerider for dagens datum. Material och atmosfarer ar visuella modeller.", + "source": "https://ssd.jpl.nasa.gov/planets/approx_pos.html ; https://ssd.jpl.nasa.gov/planets/phys_par.html ; https://nssdc.gsfc.nasa.gov/planetary/factsheet/", + "notes": "Keplerbanor med JPL J2000-element och sideriska perioder. Epok 2000-01-01 12:00 UTC (approximation av J2000 TT). Fasta tvåkroppsbanor, utan sekulära störningar, precession eller N-kroppsintegration; jordens bana approximeras med Earth/Moon-barycentrum. Rotationstid anges positivt om den följer den lutade axelns riktning: Venus och Uranus får retrograd rotation genom axialTiltDegrees > 90, inte dubbelt negativ rotationstid. RotationDegrees och poleLongitudeDegrees är justerbara visuella epokfaser, inte IAU-kalibrerade meridianer. Gasjättars rotation avser en representativ siderisk period; väder har separat vind. Radier/material/atmosfärer behåller spelbara visuella modeller.", "systems": [ { "id": "sol", @@ -50,7 +50,8 @@ "ghostStrength": 0.04, "ghostSpacing": 0.42, "chromaticAberration": 0.08 - } + }, + "massKg": 1.98847e+30 } ], "planets": [ @@ -64,12 +65,13 @@ "axialTiltDegrees": 0.034, "rotationDegrees": 15, "orbit": { - "semiMajorAxisAu": 0.3871, - "eccentricity": 0.2056, - "phaseDegrees": 160, - "inclinationDegrees": 7.0, - "ascendingNodeDegrees": 48.3, - "periapsisDegrees": 29.1 + "semiMajorAxisAu": 0.38709927, + "eccentricity": 0.20563593, + "inclinationDegrees": 7.00497902, + "ascendingNodeDegrees": 48.33076593, + "periapsisDegrees": 29.12703035, + "meanAnomalyDegrees": 174.79252722, + "periodDays": 87.96925717 }, "surfaceType": "rocky", "surfaceColor": [ @@ -90,7 +92,10 @@ 0 ], "composition": "Mycket tunn exosfär" - } + }, + "rotationPeriodHours": 1407.5088, + "massKg": 3.30103e+23, + "poleLongitudeDegrees": 0 }, { "id": "venus", @@ -102,12 +107,13 @@ "axialTiltDegrees": 177.4, "rotationDegrees": 20, "orbit": { - "semiMajorAxisAu": 0.7233, - "eccentricity": 0.0068, - "phaseDegrees": 75, - "inclinationDegrees": 3.4, - "ascendingNodeDegrees": 76.7, - "periapsisDegrees": 54.9 + "semiMajorAxisAu": 0.72333566, + "eccentricity": 0.00677672, + "inclinationDegrees": 3.39467605, + "ascendingNodeDegrees": 76.67984255, + "periapsisDegrees": 54.92262463, + "meanAnomalyDegrees": 50.37663232, + "periodDays": 224.70079921 }, "surfaceType": "cloudCovered", "surfaceColor": [ @@ -128,7 +134,10 @@ 0.22 ], "composition": "Koldioxid, kväve och svavelsyramoln" - } + }, + "rotationPeriodHours": 5832.432, + "massKg": 4.86731e+24, + "poleLongitudeDegrees": 0 }, { "id": "earth", @@ -140,12 +149,13 @@ "axialTiltDegrees": 23.44, "rotationDegrees": 135, "orbit": { - "semiMajorAxisAu": 1.0, - "eccentricity": 0.0167, - "phaseDegrees": 0, - "inclinationDegrees": 0, + "semiMajorAxisAu": 1.00000261, + "eccentricity": 0.01671123, + "inclinationDegrees": -1.531e-05, "ascendingNodeDegrees": 0, - "periapsisDegrees": 0 + "periapsisDegrees": 102.93768193, + "meanAnomalyDegrees": -2.47311027, + "periodDays": 365.25635535 }, "surfaceType": "terrestrial", "surfaceColor": [ @@ -153,68 +163,84 @@ 0.39, 0.2 ], - "surfaceTexture": "Textures/earth-color.jpg", - "materials": { - "enabled": true, - "flatMaterial": "leafy-grass", - "slopeMaterial": "rock-ground", - "scaleMeters": 8, - "fadeDistanceMeters": 2000, - "strength": 0.8 - }, - "clouds": { - "enabled": true, - "densityTexturePath": "Clouds/earth-clouds.png", - "densityTextureStrength": 1.0, - "baseAltitudeMeters": 2100, - "thicknessMeters": 4300, - "coverage": 0.40, - "density": 0.85, - "shapeScaleMeters": 18000, - "detailScaleMeters": 2400, - "erosion": 0.45, - "cloudType": 0.9, - "windSpeedMetersPerSecond": 18, - "windDirectionDegrees": 25, - "extinctionPerMeter": 0.0007, - "scatteringAlbedo": 0.96, - "anisotropy": 0.62, - "silverLiningStrength": 0.7, - "ambientStrength": 0.6, - "sunlightStrength": 1.0, - "sunsetStrength": 1.4, - "shadowStrength": 0.7, - "viewSteps": 48, - "lightSteps": 4, - "resolutionScale": 0.4, - "maxViewDistanceMeters": 220000 - }, - "ocean": { - "enabled": true, - "maskFromSurfaceTexture": true, - "seaLevelMeters": 0, - "depthMeters": 4000, - "waveAmplitudeMeters": 1.6, - "waveLengthMeters": 72, - "windDirectionDegrees": 35, - "waveSpeed": 1.0, - "choppiness": 0.65, - "roughness": 0.14, - "reflectionStrength": 1.0, - "refractionStrength": 0.018, - "foamStrength": 0.65, - "deepColor": [0.006, 0.045, 0.075], - "shallowColor": [0.025, 0.28, 0.24], - "absorption": [0.12, 0.045, 0.022] - }, + "surfaceTexture": "Textures/earth-color.jpg", + "materials": { + "enabled": true, + "flatMaterial": "leafy-grass", + "slopeMaterial": "rock-ground", + "scaleMeters": 8, + "fadeDistanceMeters": 2000, + "strength": 0.8 + }, + "clouds": { + "enabled": true, + "densityTexturePath": "Clouds/earth-clouds.png", + "densityTextureStrength": 1.0, + "baseAltitudeMeters": 2100, + "thicknessMeters": 4300, + "coverage": 0.4, + "density": 0.85, + "shapeScaleMeters": 18000, + "detailScaleMeters": 2400, + "erosion": 0.45, + "cloudType": 0.9, + "windSpeedMetersPerSecond": 18, + "windDirectionDegrees": 25, + "extinctionPerMeter": 0.0007, + "scatteringAlbedo": 0.96, + "anisotropy": 0.62, + "silverLiningStrength": 0.7, + "ambientStrength": 0.6, + "sunlightStrength": 1.0, + "sunsetStrength": 1.4, + "shadowStrength": 0.7, + "viewSteps": 48, + "lightSteps": 4, + "resolutionScale": 0.4, + "maxViewDistanceMeters": 220000 + }, + "ocean": { + "enabled": true, + "maskFromSurfaceTexture": true, + "seaLevelMeters": 0, + "depthMeters": 4000, + "waveAmplitudeMeters": 1.6, + "waveLengthMeters": 72, + "windDirectionDegrees": 35, + "waveSpeed": 1.0, + "choppiness": 0.65, + "roughness": 0.14, + "reflectionStrength": 1.0, + "refractionStrength": 0.018, + "foamStrength": 0.65, + "deepColor": [ + 0.006, + 0.045, + 0.075 + ], + "shallowColor": [ + 0.025, + 0.28, + 0.24 + ], + "absorption": [ + 0.12, + 0.045, + 0.022 + ] + }, "terrain": { - "heightmap": "Textures/earth-height.jpg", - "regionalHeight": { - "file": "Heightdata/Copernicus/alps_n46_e007.f32", - "width": 3600, "height": 3600, - "westDegrees": 7, "southDegrees": 46, - "eastDegrees": 8, "northDegrees": 47, "fadeDegrees": 0.04 - }, + "heightmap": "Textures/earth-height.jpg", + "regionalHeight": { + "file": "Heightdata/Copernicus/alps_n46_e007.f32", + "width": 3600, + "height": 3600, + "westDegrees": 7, + "southDegrees": 46, + "eastDegrees": 8, + "northDegrees": 47, + "fadeDegrees": 0.04 + }, "heightmapMinMeters": 0, "heightmapMaxMeters": 10848, "heightmapGamma": 1.0, @@ -250,7 +276,8 @@ "phaseDegrees": 60, "inclinationDegrees": 5.1, "ascendingNodeDegrees": 0, - "periapsisDegrees": 0 + "periapsisDegrees": 0, + "periodDays": 27.321661 }, "surfaceType": "rocky", "surfaceColor": [ @@ -271,9 +298,14 @@ 0 ], "composition": "Mycket tunn exosfär" - } + }, + "rotationPeriodHours": 655.719864, + "massKg": 7.342e+22 } - ] + ], + "rotationPeriodHours": 23.93447232, + "massKg": 5.97217e+24, + "poleLongitudeDegrees": 0 }, { "id": "mars", @@ -285,12 +317,13 @@ "axialTiltDegrees": 25.2, "rotationDegrees": 20, "orbit": { - "semiMajorAxisAu": 1.5237, - "eccentricity": 0.0934, - "phaseDegrees": 140, - "inclinationDegrees": 1.8, - "ascendingNodeDegrees": 49.6, - "periapsisDegrees": 286.5 + "semiMajorAxisAu": 1.52371034, + "eccentricity": 0.0933941, + "inclinationDegrees": 1.84969142, + "ascendingNodeDegrees": 49.55953891, + "periapsisDegrees": -73.5031685, + "meanAnomalyDegrees": 19.39019754, + "periodDays": 686.9795859 }, "surfaceType": "rocky", "surfaceColor": [ @@ -311,7 +344,10 @@ 0.36 ], "composition": "Tunn koldioxidatmosfär" - } + }, + "rotationPeriodHours": 24.62296224, + "massKg": 6.41691e+23, + "poleLongitudeDegrees": 0 }, { "id": "jupiter", @@ -323,12 +359,13 @@ "axialTiltDegrees": 3.1, "rotationDegrees": 20, "orbit": { - "semiMajorAxisAu": 5.2034, - "eccentricity": 0.0489, - "phaseDegrees": 220, - "inclinationDegrees": 1.3, - "ascendingNodeDegrees": 100.5, - "periapsisDegrees": 273.9 + "semiMajorAxisAu": 5.202887, + "eccentricity": 0.04838624, + "inclinationDegrees": 1.30439695, + "ascendingNodeDegrees": 100.47390909, + "periapsisDegrees": -85.74542926, + "meanAnomalyDegrees": 19.66796068, + "periodDays": 4332.82012875 }, "surfaceType": "gasGiant", "surfaceColor": [ @@ -372,7 +409,10 @@ ], "fineBandContrast": 0.16, "cloudTurbulence": 0.55 - } + }, + "rotationPeriodHours": 9.92496, + "massKg": 1.898125e+27, + "poleLongitudeDegrees": 0 }, { "id": "saturn", @@ -384,12 +424,13 @@ "axialTiltDegrees": 26.7, "rotationDegrees": 0, "orbit": { - "semiMajorAxisAu": 9.5371, - "eccentricity": 0.052, - "phaseDegrees": 290, - "inclinationDegrees": 2.5, - "ascendingNodeDegrees": 113.7, - "periapsisDegrees": 339.4 + "semiMajorAxisAu": 9.53667594, + "eccentricity": 0.05386179, + "inclinationDegrees": 2.48599187, + "ascendingNodeDegrees": 113.66242448, + "periapsisDegrees": -21.06354617, + "meanAnomalyDegrees": -42.64463408, + "periodDays": 10755.6986445 }, "surfaceType": "gasGiant", "surfaceColor": [ @@ -589,7 +630,10 @@ 0.76, 0.69 ] - } + }, + "rotationPeriodHours": 10.65624, + "massKg": 5.68317e+26, + "poleLongitudeDegrees": 0 }, { "id": "uranus", @@ -601,12 +645,13 @@ "axialTiltDegrees": 97.8, "rotationDegrees": 0, "orbit": { - "semiMajorAxisAu": 19.1913, - "eccentricity": 0.0472, - "phaseDegrees": 45, - "inclinationDegrees": 0.8, - "ascendingNodeDegrees": 74.0, - "periapsisDegrees": 97.0 + "semiMajorAxisAu": 19.18916464, + "eccentricity": 0.04725744, + "inclinationDegrees": 0.77263783, + "ascendingNodeDegrees": 74.01692503, + "periapsisDegrees": 96.93735127, + "meanAnomalyDegrees": 142.28382821, + "periodDays": 30687.1530015 }, "surfaceType": "iceGiant", "surfaceColor": [ @@ -650,7 +695,10 @@ ], "fineBandContrast": 0.025, "cloudTurbulence": 0.04 - } + }, + "rotationPeriodHours": 17.23992, + "massKg": 8.68099e+25, + "poleLongitudeDegrees": 0 }, { "id": "neptune", @@ -662,12 +710,13 @@ "axialTiltDegrees": 28.3, "rotationDegrees": 0, "orbit": { - "semiMajorAxisAu": 30.069, - "eccentricity": 0.0086, - "phaseDegrees": 105, - "inclinationDegrees": 1.8, - "ascendingNodeDegrees": 131.8, - "periapsisDegrees": 273.2 + "semiMajorAxisAu": 30.06992276, + "eccentricity": 0.00859048, + "inclinationDegrees": 1.77004347, + "ascendingNodeDegrees": 131.78422574, + "periapsisDegrees": -86.81946347, + "meanAnomalyDegrees": -100.08479196, + "periodDays": 60190.02963 }, "surfaceType": "iceGiant", "surfaceColor": [ @@ -711,9 +760,13 @@ ], "fineBandContrast": 0.05, "cloudTurbulence": 0.18 - } + }, + "rotationPeriodHours": 16.11, + "massKg": 1.024092e+26, + "poleLongitudeDegrees": 0 } - ] + ], + "epochUtc": "2000-01-01T12:00:00Z" } ] } diff --git a/README.md b/README.md index 4132092..0118e9d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,19 @@ Välj **Debug** i P-menyn för en panel till vänster med asynkron GPU-tid för - Jorden har ett separat sfäriskt hav med animerade vågor, Fresnelreflektion, solljusglitter, skum, refraktion och djupberoende ljusabsorption. Havsbotten och kustmasken används av både terrängen och kollisionen. `ocean` i planetens JSON styr vattenmaterialet; F6 visar havet nära ytan. - Sfäriska 3D-moln använder fraktal noise och Worley-noise, vind, självskuggning och ljus från systemets solar. Valfria equirektangulära gråskalekartor styr den geografiska täckningen; jordens `Content/Clouds/earth-clouds.png` används i solsystemets JSON, där vitt betyder moln och svart klart. Molnen skuggar mark och hav, dämpar solglöden och speglas i vattnet. `clouds` i planetens JSON styr karta, utseende och kvalitet. -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. Planeter och månar följer elliptiska Keplerbanor och roterar om sina axlar. Terrängen använder proceduriell relief; ingen hydrologisk erosionssimulering eller atmosfär med multipel spridning beräknas. + +## Simulationstid + +Öppna **P → DEBUG ON**. Tidsreglaget i menyn går från **0× till 100×**; knapparna 0×, 1×, 10× och 100× ger snabba val. Vänster/höger ändrar med 1×, eller 10× med Shift. **1× är standard och motsvarar verklig tid**, 0× fryser himlakroppar och vädereffekter medan kameran fortfarande kan användas. Menyn pausar simulationen; vald hastighet börjar gälla när menyn stängs. Debugpanelen visar både tidsfaktor och simulerat UTC-datum. + +I `config.json` styr `simulation.startUtc` startdatum och `simulation.timeScale` starthastighet. Standarddatumet är J2000, `2000-01-01T12:00:00Z`, för en reproducerbar start. Datumet kan ändras oberoende av solsystemets banepok. + +Solsystemets JSON innehåller JPL:s banparametrar vid J2000, sideriska omlopps- och rotationstider samt planetmassor. Venus och Uranus roterar retrogradt. Månen kretsar kring den rörliga jorden. Terräng, texturer, moln, hav och kollision använder samma kroppsrotation, och en spelare på marken följer planetens rörelse. Nära planeten följer fri kamera dess omloppsförflyttning; under 100 meters höjd följer den även markens rotation. Från rymden syns axelrotationen. + +Modellen använder analytiska tvåkroppsbanor med fasta banelement. Den inkluderar inte N-kroppsstörningar, precession eller exakta IAU-meridianer; jordbanan approximerar jord–måne-barycentrum. Se [JPL:s banelement](https://ssd.jpl.nasa.gov/planets/approx_pos.html) och [fysiska planetdata](https://ssd.jpl.nasa.gov/planets/phys_par.html). Benchmarkrutterna fryser världstiden på den aktuella tidpunkten under mätningen och återställer sedan kameran framför den aktuella jorden, så att mätningar kan upprepas. + +`--simulation-report` kör den riktiga OpenGL-renderingen vid 0 och 6 timmar samt kontrollerar roterad terräng och debugmenyn. Bilder sparas i appens `graphics-report/`. ## Galaxer och nebulosor diff --git a/config.json b/config.json index faac393..54496c1 100644 --- a/config.json +++ b/config.json @@ -46,5 +46,9 @@ "longitudeDegrees": 20, "latitudeDegrees": 20, "speedMetersPerSecond": 200000 + }, + "simulation": { + "startUtc": "2000-01-01T12:00:00Z", + "timeScale": 1 } } diff --git a/src/Universe.App/Graphics/AdaptivePlanetMesh.cs b/src/Universe.App/Graphics/AdaptivePlanetMesh.cs index 5abb2d4..d383946 100644 --- a/src/Universe.App/Graphics/AdaptivePlanetMesh.cs +++ b/src/Universe.App/Graphics/AdaptivePlanetMesh.cs @@ -34,11 +34,12 @@ internal sealed class AdaptivePlanetMesh : IDisposable public AdaptivePlanetMesh(GL gl, PlanetData planet) { _gl = gl; - _planet = planet; + // Meshes live in a stable body frame. Spin is applied on the GPU, never by rebuilding terrain. + _planet = planet with { Orientation = PlanetOrientation.Identity }; for (var face = 0; face < 6; face++) { var key = new TerrainPatchKey(face, 0, 0, 0); - var data = TerrainPatchBuilder.Build(planet, key); + var data = TerrainPatchBuilder.Build(_planet, key); _tiles[key] = new() { Data = data, Mesh = new(gl, 9, data.Vertices, data.Indices) }; } } diff --git a/src/Universe.App/Graphics/GraphicsReport.cs b/src/Universe.App/Graphics/GraphicsReport.cs index f5c4000..8521c81 100644 --- a/src/Universe.App/Graphics/GraphicsReport.cs +++ b/src/Universe.App/Graphics/GraphicsReport.cs @@ -13,6 +13,50 @@ namespace Universe.App.Graphics; /// Offscreen tests of the production shaders; no desktop capture or mouse control. internal static class GraphicsReport { + public static void RunSimulation(GL gl, UniverseRenderer renderer, UniverseNeighborhood initial, double cellSize, int width, int height) + { + var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory); + var center = UniversePosition.AtCellCenter(initial.ActiveSystem.Coordinate, cellSize); + var camera = new FlyCamera(center, cellSize); + var basis = initial.ActiveSystem; + camera.SetInitialView(basis, new StartupConfiguration { PlanetId = "earth", DistanceInRadii = 3, LongitudeDegrees = 20, LatitudeDegrees = 20 }); + var earth = basis.Planets.Single(p => p.Id == "earth"); + var offset = camera.Position.RelativeTo(center.Add(earth.PositionMeters, cellSize), cellSize); + var forward = camera.Forward; var up = camera.Up; + float[] Frame(double seconds, string file) + { + var system = Universe.Core.Physics.CelestialSimulation.Evaluate(basis, basis.EpochSecondsSinceJ2000 + seconds); + var p = system.Planets.Single(p => p.Id == "earth"); + camera.SetPlayerPose(center.Add(p.PositionMeters + offset, cellSize), forward, up, resetMotionHistory: true); + renderer.SimulationTimeSeconds = seconds; + for (var i = 0; i < 24; i++) renderer.Render(camera, initial with { ActiveSystem = system }, 0); + gl.Finish(); var pixels = Read(gl, width, height); + if (gl.GetError() != GLEnum.NoError || pixels.Any(v => !float.IsFinite(v))) + throw new InvalidOperationException("Simulation produced invalid OpenGL output."); + WritePng(Path.Combine(directory, file), width, height, pixels, false); + return pixels; + } + var first = Frame(0, "earth-time-0h.png"); + var rotated = Frame(21600, "earth-time-6h.png"); + var difference = first.Zip(rotated, (a,b) => System.Math.Abs(a-b)).Average(); + if (difference < .002) throw new InvalidOperationException($"Earth spin did not visibly change the production rendering: {difference}."); + renderer.DrawMenu(2, true, 100, Universe.Core.Physics.SimulationClock.J2000.AddHours(6)); + gl.Finish(); WritePng(Path.Combine(directory, "time-debug-menu.png"), width, height, Read(gl, width, height), false); + // Sample a detailed surface after spin: mesh generation, culling and displacement must use the body frame. + var after = Universe.Core.Physics.CelestialSimulation.Evaluate(basis, basis.EpochSecondsSinceJ2000 + 21600); + var planet = after.Planets.Single(p => p.Id == "earth"); + var lat = 46.5 * System.Math.PI / 180; var lon = -7.5 * System.Math.PI / 180; + var radial = planet.Orientation.ToWorld(new(System.Math.Cos(lat)*System.Math.Cos(lon), System.Math.Sin(lat), System.Math.Cos(lat)*System.Math.Sin(lon))); + var tangent = DVector3.Cross(radial, planet.Orientation.ToWorld(new(0,1,0))).Normalized(); + var radius = TerrainHeight.Sample(planet, radial); + camera.SetPlayerPose(center.Add(planet.PositionMeters + radial * (radius + 400), cellSize), (tangent - radial * .3).Normalized(), radial, true); + for (var i = 0; i < 72; i++) renderer.Render(camera, initial with { ActiveSystem = after }, 0); + gl.Finish(); var ground = Read(gl, width, height); + if (gl.GetError() != GLEnum.NoError) throw new InvalidOperationException("Rotating terrain GL error."); + WritePng(Path.Combine(directory, "earth-rotated-terrain.png"), width, height, ground, false); + Console.WriteLine($"Simulation GPU report passed: Earth 0/6h visual difference {difference:F5}, rotated terrain {renderer.TerrainTiles} tiles, LOD {renderer.TerrainDeepestLevel}; debug time menu. Images: {directory}"); + } + public static void RunStarTrails(GL gl,StarMotionBlurSettings settings,int width,int height) { using var trails=new StarTrailRenderer(gl,settings with {Enabled=true}); diff --git a/src/Universe.App/Graphics/PauseMenu.cs b/src/Universe.App/Graphics/PauseMenu.cs index 313e7b0..736c598 100644 --- a/src/Universe.App/Graphics/PauseMenu.cs +++ b/src/Universe.App/Graphics/PauseMenu.cs @@ -49,7 +49,18 @@ internal sealed class PauseMenu : IDisposable return -1; } - public void Draw(int width, int height, bool debugEnabled) + public static double? TimeScaleHit(double x, double y, int width, int height) + { + var (s, cx, cy) = Layout(width, height); + x = (x - cx) / s; y = (y - cy) / s; + if (x >= 290 && x <= 610 && y >= -80 && y <= -40) return System.Math.Round((x - 290) / 320 * 100); + if (y >= 0 && y <= 38) + for (var i = 0; i < 4; i++) + if (x >= 290 + i * 82 && x <= 364 + i * 82) return new double[] { 0, 1, 10, 100 }[i]; + return null; + } + + public void Draw(int width, int height, bool debugEnabled, double timeScale = 1, DateTimeOffset? simulationUtc = null) { var (s, cx, cy) = Layout(width, height); var data = new List(); @@ -84,6 +95,26 @@ internal sealed class PauseMenu : IDisposable Text(label, cx, y + 12*s, (i >= 3 ? 3.35 : 4) * s, .92f, .95f, 1); } Text("P OR ESC TO RESUME", cx, cy + 250*s, 2*s, .6f, .7f, .8f); + if (debugEnabled) + { + Rect(cx + 250*s, cy - 180*s, 400*s, 340*s, .025f, .04f, .075f); + Text("WORLD TIME", cx + 450*s, cy - 150*s, 3.5*s, .9f, .95f, 1); + Text($"SPEED {timeScale:0.##} X", cx + 450*s, cy - 108*s, 2.8*s, .4f, .85f, 1); + Rect(cx + 290*s, cy - 64*s, 320*s, 8*s, .13f, .24f, .34f); + Rect(cx + 290*s, cy - 64*s, timeScale / 100 * 320*s, 8*s, .3f, .7f, .95f); + Rect(cx + (286 + timeScale / 100 * 320)*s, cy - 74*s, 8*s, 28*s, .8f, .95f, 1); + Text("0 X 100 X", cx + 450*s, cy - 30*s, 1.8*s, .7f, .8f, .9f); + var speeds = new[] { "0 X", "1 X", "10 X", "100 X" }; + for (var i = 0; i < speeds.Length; i++) + { + Rect(cx + (290 + i * 82)*s, cy, 74*s, 38*s, .08f, .18f, .28f); + Text(speeds[i], cx + (327 + i * 82)*s, cy + 12*s, 2*s, .9f, .95f, 1); + } + Text("LEFT / RIGHT TO ADJUST", cx + 450*s, cy + 60*s, 2*s, .65f, .8f, .9f); + Text("PAUSED IN MENU", cx + 450*s, cy + 88*s, 2*s, .65f, .8f, .9f); + Text((simulationUtc ?? Universe.Core.Physics.SimulationClock.J2000).ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture), + cx + 450*s, cy + 121*s, 2*s, .7f, .85f, .95f); + } _mesh.ReplaceVertices(data.ToArray()); _mesh.Draw(GLEnum.Triangles); } public void Dispose() => _mesh.Dispose(); diff --git a/src/Universe.App/Graphics/PerformanceOverlay.cs b/src/Universe.App/Graphics/PerformanceOverlay.cs index 88bd2f4..6a658c6 100644 --- a/src/Universe.App/Graphics/PerformanceOverlay.cs +++ b/src/Universe.App/Graphics/PerformanceOverlay.cs @@ -10,7 +10,8 @@ internal sealed class PerformanceOverlay : IDisposable public PerformanceOverlay(GL gl) => _mesh = new(gl, 6, [], dynamic: true); public void Draw(int width, int height, IReadOnlyList> samples, - double cpuMilliseconds, double fps, int terrainTiles, int terrainLod, int pendingTiles, string? probePlanetId) + double cpuMilliseconds, double fps, int terrainTiles, int terrainLod, int pendingTiles, string? probePlanetId, + double timeScale = 1, DateTimeOffset? simulationUtc = null) { var values = samples.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); var rows = new List<(string Label, string Value, bool Emphasis)>(); @@ -44,6 +45,8 @@ internal sealed class PerformanceOverlay : IDisposable rows.Add(("FPS", $"{Number(fps, "F1")}", false)); rows.Add(("TERRAIN TILES", terrainTiles.ToString(CultureInfo.InvariantCulture), false)); rows.Add(("LOD / PENDING", $"{terrainLod} / {pendingTiles}", false)); + rows.Add(("TIME SCALE", $"{Number(timeScale, "F1")} X", true)); + if (simulationUtc is { } date) rows.Add(("WORLD UTC", date.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), false)); var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.25); var x = 14 * scale; diff --git a/src/Universe.App/Graphics/Shaders/terrain.vert.glsl b/src/Universe.App/Graphics/Shaders/terrain.vert.glsl index 5095187..dd8255e 100644 --- a/src/Universe.App/Graphics/Shaders/terrain.vert.glsl +++ b/src/Universe.App/Graphics/Shaders/terrain.vert.glsl @@ -8,6 +8,7 @@ uniform float uRadius; uniform mat4 uViewProjection; uniform float uFar; uniform mat4 uWorldToBody; +uniform mat4 uBodyToWorld; uniform vec3 uMaterialAnchor; out vec3 vWorldPosition; out vec3 vPlanetLocal; @@ -15,10 +16,11 @@ out vec3 vNormal; out float vFragDepth; out vec3 vMaterialPosition; void main() { - vWorldPosition=uChunkPosition+aPosition; - vPlanetLocal=uChunkOrigin+aPosition/uRadius; - vNormal=aNormal; - vMaterialPosition=mat3(uWorldToBody)*aPosition+uMaterialAnchor; + vec3 worldOffset=mat3(uBodyToWorld)*aPosition; + vWorldPosition=uChunkPosition+worldOffset; + vPlanetLocal=uChunkOrigin+worldOffset/uRadius; + vNormal=mat3(uBodyToWorld)*aNormal; + vMaterialPosition=aPosition+uMaterialAnchor; vec4 clip=uViewProjection*vec4(vWorldPosition,1.0); vFragDepth=clip.w+1.0; gl_Position=clip; diff --git a/src/Universe.App/Graphics/UniverseRenderer.cs b/src/Universe.App/Graphics/UniverseRenderer.cs index 1baa72c..fdd6d79 100644 --- a/src/Universe.App/Graphics/UniverseRenderer.cs +++ b/src/Universe.App/Graphics/UniverseRenderer.cs @@ -66,6 +66,7 @@ internal sealed class UniverseRenderer : IDisposable private int _width; private int _height; private double _time; + public double? SimulationTimeSeconds { get; set; } private int _probeFrameCounter; private int _probeEffectIndex; private string? _debugProbePlanetId; @@ -260,7 +261,7 @@ internal sealed class UniverseRenderer : IDisposable if (timer is null) return; _gl.Finish(); FrameTimings[name] = timer.Elapsed.TotalMilliseconds; timer.Restart(); } - _time += System.Math.Min(deltaSeconds, 0.1); + _time = SimulationTimeSeconds ?? (_time + System.Math.Min(deltaSeconds, 0.1)); TerrainTiles = 0; TerrainDeepestLevel = 0; TerrainPendingTiles = 0; TerrainNearestLevel = 0; TerrainNearestDistance = double.PositiveInfinity; @@ -355,20 +356,21 @@ internal sealed class UniverseRenderer : IDisposable } public int MenuHit(double x, double y, int width, int height) => _pauseMenu.Hit(x, y, width, height); - public void DrawMenu(int selection, bool debugEnabled) + public double? TimeScaleHit(double x, double y, int width, int height) => PauseMenu.TimeScaleHit(x, y, width, height); + public void DrawMenu(int selection, bool debugEnabled, double timeScale = 1, DateTimeOffset? simulationUtc = null) { _gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.Blend); - _pauseMenu.Selected = selection; _overlay.Use(); _pauseMenu.Draw(_width, _height, debugEnabled); + _pauseMenu.Selected = selection; _overlay.Use(); _pauseMenu.Draw(_width, _height, debugEnabled, timeScale, simulationUtc); _gl.Enable(GLEnum.DepthTest); } - public void DrawDebugOverlay(double fps) + public void DrawDebugOverlay(double fps, double timeScale = 1, DateTimeOffset? simulationUtc = null) { if (!DebugMode) return; _gl.Disable(GLEnum.DepthTest); _gl.Disable(GLEnum.Blend); _gl.DepthMask(false); _overlay.Use(); _performanceOverlay.Draw(_width, _height, DebugSamples(), CpuRenderMilliseconds, fps, - TerrainTiles, TerrainDeepestLevel, TerrainPendingTiles, _debugProbePlanetId); + TerrainTiles, TerrainDeepestLevel, TerrainPendingTiles, _debugProbePlanetId, timeScale, simulationUtc); _gl.DepthMask(true); _gl.Enable(GLEnum.DepthTest); } @@ -490,14 +492,16 @@ internal sealed class UniverseRenderer : IDisposable BindRingProfile(_surface, planet); _surface.Set("uTerrainShadows", relative.Length < planet.RadiusMeters * 1.18 && probeEffect != PlanetProbeEffect.MountainShadows ? 1 : 0); if (onlyPlanetId is null) _gpuProfiler.BeginPass($"PLANET {planet.Id.ToUpperInvariant()}"); - mesh.Draw(-relative, camera.Forward, camera.Right, camera.Up, _height, (double)_width / _height, System.Math.Tan(camera.FieldOfViewRadians * 0.5), (origin, gpu) => + _surface.SetMatrix("uBodyToWorld", planet.Orientation.BodyToWorld); + mesh.Draw(planet.Orientation.ToBody(-relative), planet.Orientation.ToBody(camera.Forward), planet.Orientation.ToBody(camera.Right), planet.Orientation.ToBody(camera.Up), _height, (double)_width / _height, System.Math.Tan(camera.FieldOfViewRadians * 0.5), (origin, gpu) => { - var anchor = planet.Orientation.ToBody(origin); + var anchor = origin; var scale = materialSettings.ScaleMeters; static double Mod(double value, double divisor) => value - System.Math.Floor(value / divisor) * divisor; _surface.Set("uMaterialAnchor", new Vector3((float)Mod(anchor.X, scale), (float)Mod(anchor.Y, scale), (float)Mod(anchor.Z, scale))); - _surface.Set("uChunkPosition", (relative + origin).ToVector3()); - _surface.Set("uChunkOrigin", (origin / planet.RadiusMeters).ToVector3()); + var worldOrigin = planet.Orientation.ToWorld(origin); + _surface.Set("uChunkPosition", (relative + worldOrigin).ToVector3()); + _surface.Set("uChunkOrigin", (worldOrigin / planet.RadiusMeters).ToVector3()); gpu.Draw(); }); if (onlyPlanetId is null) diff --git a/src/Universe.App/Runtime/FlyCamera.cs b/src/Universe.App/Runtime/FlyCamera.cs index bef0503..35c3207 100644 --- a/src/Universe.App/Runtime/FlyCamera.cs +++ b/src/Universe.App/Runtime/FlyCamera.cs @@ -12,6 +12,8 @@ public sealed class FlyCamera private DVector3 _up = new(0, 1, 0); private FocusFlight? _flight; private double _flightElapsed; + private StarSystemData? _flightSystem; + private PlanetData? _flightPlanet; private double _defaultFieldOfView = 2 * System.Math.Atan(.67); public double FieldOfViewRadians { get; private set; } = 2 * System.Math.Atan(.67); public FlyCamera(UniversePosition initialPosition, double cellSizeMeters) @@ -336,6 +338,58 @@ public sealed class FlyCamera _flight = new(travelStart, body, startRadial, radial, approachRadius, finalRadius, Forward, Up, forward, up, speed, duration, FieldOfViewRadians, fieldOfView ?? _defaultFieldOfView, departure); _flightElapsed = 0; + _flightSystem = null; _flightPlanet = null; + } + + public void ApplyCelestialMotion(UniverseNeighborhood previous, UniverseNeighborhood current, double secondsSinceJ2000) + { + var bodies = previous.NearbySystems.Append(previous.ActiveSystem) + .SelectMany(s => s.Planets.Select(p => (System: s, Planet: p, Body: World(s, p.PositionMeters)))).ToArray(); + if (_flight is { } flight) + { + if (flight.Departure is { } departure) + { + var source = bodies.FirstOrDefault(b => b.Body.RelativeTo(departure.Body, CellSizeMeters).Length < 1); + if (source.Planet is { } sourcePlanet) + { + var movedSystem = CelestialSimulation.Evaluate(source.System, secondsSinceJ2000); + var movedPlanet = movedSystem.Planets.First(p => p.Id == sourcePlanet.Id); + DVector3 TurnDeparture(DVector3 v) => movedPlanet.Orientation.ToWorld(sourcePlanet.Orientation.ToBody(v)); + var movedBody = World(movedSystem, movedPlanet.PositionMeters); + var movedDeparture = departure with { Body = movedBody, StartRadial = TurnDeparture(departure.StartRadial), TargetRadial = TurnDeparture(departure.TargetRadial) }; + flight = flight with { Departure = movedDeparture, + Start = movedBody.Add(movedDeparture.TargetRadial * departure.ClearanceRadius, CellSizeMeters) }; + } + } + if (_flightPlanet is null) + { + var target = bodies.FirstOrDefault(b => b.Body.RelativeTo(flight.Body, CellSizeMeters).Length < 1); + _flightSystem = target.System; _flightPlanet = target.Planet; + } + if (_flightSystem is not null && _flightPlanet is { } old) + { + var updated = CelestialSimulation.Evaluate(_flightSystem, secondsSinceJ2000).Planets.First(p => p.Id == old.Id); + DVector3 Turn(DVector3 v) => updated.Orientation.ToWorld(old.Orientation.ToBody(v)); + _flight = flight with { Body = World(_flightSystem, updated.PositionMeters), TargetRadial = Turn(flight.TargetRadial), + TargetForward = Turn(flight.TargetForward), TargetUp = Turn(flight.TargetUp) }; + _flightPlanet = updated; + } + else _flight = flight; + return; + } + var nearest = bodies.MinBy(b => b.Body.RelativeTo(Position, CellSizeMeters).Length / b.Planet.RadiusMeters); + if (nearest.Planet is null) return; + var offset = Position.RelativeTo(nearest.Body, CellSizeMeters); + if (offset.Length > nearest.Planet.RadiusMeters * 12) return; + var system = current.NearbySystems.Append(current.ActiveSystem).FirstOrDefault(s => s.Coordinate == nearest.System.Coordinate); + var planet = system?.Planets.FirstOrDefault(p => p.Id == nearest.Planet.Id); + if (planet is null) return; + if (offset.Length - TerrainHeight.Sample(nearest.Planet, offset.Normalized()) < 100) + { + DVector3 Turn(DVector3 v) => planet.Orientation.ToWorld(nearest.Planet.Orientation.ToBody(v)); + offset = Turn(offset); _forward = Turn(_forward); _up = Turn(_up); + } + Position = World(system!, planet.PositionMeters).Add(offset, CellSizeMeters); } public void Update(double deltaSeconds, DVector3 movement, UniverseNeighborhood neighborhood) diff --git a/src/Universe.App/Runtime/GalaxyJourneyBenchmark.cs b/src/Universe.App/Runtime/GalaxyJourneyBenchmark.cs index c1c1fde..e8c554d 100644 --- a/src/Universe.App/Runtime/GalaxyJourneyBenchmark.cs +++ b/src/Universe.App/Runtime/GalaxyJourneyBenchmark.cs @@ -28,7 +28,7 @@ internal sealed class GalaxyJourneyBenchmark : IBenchmarkSession public object? RenderEnvironment { get; set; } public GalaxyJourneyBenchmark(GalaxyUniverse galaxies, UniverseGenerator generator, StarSystemData homeSystem, PlanetData earth, - UniversePosition home, DVector3 homeForward, DVector3 homeUp) + UniversePosition home, DVector3 homeForward, DVector3 homeUp, double simulationSeconds = 0) { _galaxies = galaxies; _home = home; _homeForward = homeForward; _homeUp = homeUp; _homeBody = UniversePosition.AtCellCenter(homeSystem.Coordinate, galaxies.CellSize).Add(earth.PositionMeters, galaxies.CellSize); @@ -37,7 +37,7 @@ internal sealed class GalaxyJourneyBenchmark : IBenchmarkSession 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)); + TargetSystem = Universe.Core.Physics.CelestialSimulation.Evaluate(generator.Generate(galaxies.SelectSystem(TargetGalaxy)), simulationSeconds); 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 diff --git a/src/Universe.App/Runtime/PlayerController.cs b/src/Universe.App/Runtime/PlayerController.cs index b829190..9ca7859 100644 --- a/src/Universe.App/Runtime/PlayerController.cs +++ b/src/Universe.App/Runtime/PlayerController.cs @@ -39,6 +39,24 @@ public sealed class PlayerController _pitch = System.Math.Clamp(_pitch - dy * .0022, -1.5, 1.5); } + public void ApplyCelestialMotion(UniverseNeighborhood previous, UniverseNeighborhood current) + { + if (Nearest(previous) is not { } old) return; + var system = current.NearbySystems.Append(current.ActiveSystem).FirstOrDefault(s => s.Coordinate == old.System.Coordinate); + var planet = system?.Planets.FirstOrDefault(p => p.Id == old.Planet.Id); + if (planet is null) return; + var body = UniversePosition.AtCellCenter(system!.Coordinate, _cellSize).Add(planet.PositionMeters, _cellSize); + var relative = Position.RelativeTo(old.Body, _cellSize); + // Use the rotating local frame near the surface, including jumps: the player inherits the ground's + // rotational motion instead of the planet sliding hundreds of metres sideways during a jump. + if (Grounded || relative.Length - TerrainHeight.Sample(old.Planet, relative.Normalized()) <= System.Math.Max(2000, old.Planet.AtmosphereHeightMeters)) + { + DVector3 Rotate(DVector3 v) => planet.Orientation.ToWorld(old.Planet.Orientation.ToBody(v)); + relative = Rotate(relative); _heading = Rotate(_heading); Up = Rotate(Up); _velocity = Rotate(_velocity); + } + Position = body.Add(relative, _cellSize); + } + public void Update(double dt, double forward, double side, bool jump, bool crouch, UniverseNeighborhood neighborhood) { if (!double.IsFinite(dt) || dt <= 0) return; diff --git a/src/Universe.App/UniverseApplication.cs b/src/Universe.App/UniverseApplication.cs index 0d4e525..e872c14 100644 --- a/src/Universe.App/UniverseApplication.cs +++ b/src/Universe.App/UniverseApplication.cs @@ -10,6 +10,7 @@ using Universe.Core.Generation; using Universe.Core.Math; using Universe.Core.World; using Universe.Core.Configuration; +using Universe.Core.Physics; namespace Universe.App; @@ -20,6 +21,7 @@ public sealed class UniverseApplication : IDisposable private readonly UniverseStreamer _streamer; private readonly GalaxyUniverse _galaxies; private readonly StarMotionBlurSettings _starMotionBlur; + private readonly SimulationClock _clock; private readonly IReadOnlyList _planetHotkeys; private readonly StarSystemData _homeSystem; private readonly PlanetData _homeEarth; @@ -52,6 +54,7 @@ public sealed class UniverseApplication : IDisposable { var configPath = Path.Combine(AppContext.BaseDirectory, "config.json"); var configuration = EngineConfiguration.Load(configPath); + _clock = new(configuration.Simulation); _starMotionBlur = configuration.StarMotionBlur; _planetHotkeys = configuration.Startup.PlanetHotkeys; var catalogPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.KnownSolarSystemsFile)); @@ -60,8 +63,9 @@ public sealed class UniverseApplication : IDisposable Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.Galaxies.KnownGalaxiesFile))); _generator = new(configuration.Seed, configuration.CellSizeMeters, catalog, configuration.BlackHoles, _galaxies); _streamer = new(_generator); + _streamer.SimulationSeconds = _clock.SecondsSinceJ2000; var initialSystem = configuration.ResolveStartup(catalog).System; - _homeSystem = _generator.Generate(catalog.GetById("sol").Coordinate); + _homeSystem = _streamer.AtCurrentTime(_generator.Generate(catalog.GetById("sol").Coordinate)); _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'."); var startCoordinate = initialSystem.Coordinate; @@ -79,7 +83,7 @@ public sealed class UniverseApplication : IDisposable var options = WindowOptions.Default with { 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" or "--galaxy-journey-report" or "--star-trails-report"), + IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--simulation-report" or "--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(1440, 900), VSync = true, PreferredDepthBufferBits = 24, @@ -100,6 +104,11 @@ public sealed class UniverseApplication : IDisposable var gl = _window.CreateOpenGL(); _input = _window.CreateInput(); _renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y, _galaxies, _starMotionBlur); + if (Environment.GetCommandLineArgs().Contains("--simulation-report")) + { + GraphicsReport.RunSimulation(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); + _window.Close(); return; + } if (Environment.GetCommandLineArgs().Contains("--star-trails-report")) { GraphicsReport.RunStarTrails(gl,_starMotionBlur,_window.FramebufferSize.X,_window.FramebufferSize.Y); @@ -164,6 +173,16 @@ public sealed class UniverseApplication : IDisposable if (_menuOpen) { _mouseDelta = Vector2.Zero; return; } + var previous = _neighborhood; + _clock.Advance(deltaSeconds); + _streamer.SimulationSeconds = _clock.SecondsSinceJ2000; + if (_clock.TimeScale > 0) + { + _neighborhood = _streamer.Refresh(previous); + if (_player is { } movingPlayer) movingPlayer.ApplyCelestialMotion(previous, _neighborhood); + else _camera.ApplyCelestialMotion(previous, _neighborhood, _clock.SecondsSinceJ2000); + } + if (_mouseCaptured) { if (_player is null) _camera.Rotate(_mouseDelta.X, _mouseDelta.Y); @@ -216,12 +235,13 @@ public sealed class UniverseApplication : IDisposable if (_renderer is not null) { _renderer.DebugMode = _debugMode; + _renderer.SimulationTimeSeconds = _clock.ElapsedSeconds; _renderer.DebugProbeEnabled = _debugMode && !_menuOpen; } _renderer?.Render(_camera, _neighborhood, _menuOpen ? 0 : deltaSeconds); - _renderer?.DrawDebugOverlay(1 / _frameTime); + _renderer?.DrawDebugOverlay(1 / _frameTime, _clock.TimeScale, _clock.Utc); if (_benchmark is { HasRun: true } benchmark) _renderer?.DrawBenchmarkOverlay(benchmark); - if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode); + if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode, _clock.TimeScale, _clock.Utc); _benchmarkNeedsInitialFrame = false; } @@ -252,6 +272,8 @@ public sealed class UniverseApplication : IDisposable if (_menuOpen) { if (key == Key.Escape) SetMenu(false); + else if (_debugMode && key is Key.Left or Key.Right) + _clock.TimeScale += (key == Key.Right ? 1 : -1) * (IsDown(Key.ShiftLeft) || IsDown(Key.ShiftRight) ? 10 : 1); else if (key == Key.Up) _menuSelection = (_menuSelection + 6) % 7; else if (key == Key.Down) _menuSelection = (_menuSelection + 1) % 7; else if (key is Key.Enter or Key.KeypadEnter) ActivateMenu(); @@ -337,6 +359,9 @@ public sealed class UniverseApplication : IDisposable { if (_menuOpen) { + if (_debugMode && mouse.IsButtonPressed(MouseButton.Left) + && _renderer?.TimeScaleHit(position.X, position.Y, _window.Size.X, _window.Size.Y) is { } rate) + _clock.TimeScale = rate; var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1; if (hit >= 0) _menuSelection = hit; return; @@ -363,6 +388,8 @@ public sealed class UniverseApplication : IDisposable { if (!_menuOpen || button != MouseButton.Left) return; var position = mouse.Position; + if (_debugMode && _renderer?.TimeScaleHit(position.X, position.Y, _window.Size.X, _window.Size.Y) is { } rate) + { _clock.TimeScale = rate; return; } var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1; if (hit >= 0) { _menuSelection = hit; ActivateMenu(); } } @@ -390,7 +417,8 @@ public sealed class UniverseApplication : IDisposable _debugMode = false; _player = null; _benchmark = CreateGalaxyJourney(); _benchmarkNeedsInitialFrame = true; - _camera.SetPlayerPose(_homeCameraPosition, _homeCameraForward, _homeCameraUp, resetMotionHistory: true); + var home = CurrentHomeView(); + _camera.SetPlayerPose(home.Position, home.Forward, home.Up, resetMotionHistory: true); UpdateNeighborhood(); SetMenu(false); return; } if (_menuSelection is 3 or 4) @@ -417,8 +445,9 @@ public sealed class UniverseApplication : IDisposable private void StartBenchmark(bool unknownPlanet) { _camera.RestoreDefaultFieldOfView(); - var system = _homeSystem; - var planet = _homeEarth; + var home = CurrentHomeView(); + var system = home.System; + var planet = home.Earth; if (unknownPlanet) { var candidate = _neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem) @@ -441,7 +470,7 @@ public sealed class UniverseApplication : IDisposable _debugMode = false; _player = null; _benchmark = new BenchmarkRunner(system, planet, _generator.CellSizeMeters, _camera, - _homeCameraPosition, _homeCameraForward, _homeCameraUp); + home.Position, home.Forward, home.Up); _benchmarkNeedsInitialFrame = true; UpdateNeighborhood(); SetMenu(false); @@ -450,14 +479,27 @@ public sealed class UniverseApplication : IDisposable private GalaxyJourneyBenchmark CreateGalaxyJourney() { _camera.RestoreDefaultFieldOfView(); - var journey = new GalaxyJourneyBenchmark(_galaxies, _generator, _homeSystem, _homeEarth, - _homeCameraPosition, _homeCameraForward, _homeCameraUp); + var home = CurrentHomeView(); + var journey = new GalaxyJourneyBenchmark(_galaxies, _generator, home.System, home.Earth, + home.Position, home.Forward, home.Up, _clock.SecondsSinceJ2000); 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 (StarSystemData System, PlanetData Earth, UniversePosition Position, DVector3 Forward, DVector3 Up) CurrentHomeView() + { + var system = _streamer.AtCurrentTime(_homeSystem); + var earth = system.Planets.First(p => p.Id == _homeEarth.Id); + var center = UniversePosition.AtCellCenter(system.Coordinate, _generator.CellSizeMeters); + var oldBody = center.Add(_homeEarth.PositionMeters, _generator.CellSizeMeters); + var body = center.Add(earth.PositionMeters, _generator.CellSizeMeters); + DVector3 Turn(DVector3 v) => earth.Orientation.ToWorld(_homeEarth.Orientation.ToBody(v)); + return (system, earth, body.Add(Turn(_homeCameraPosition.RelativeTo(oldBody, _generator.CellSizeMeters)), _generator.CellSizeMeters), + Turn(_homeCameraForward), Turn(_homeCameraUp)); + } + private void OnClosing() { _renderer?.Dispose(); diff --git a/src/Universe.Core/Configuration/EngineConfiguration.cs b/src/Universe.Core/Configuration/EngineConfiguration.cs index 26e6032..f972147 100644 --- a/src/Universe.Core/Configuration/EngineConfiguration.cs +++ b/src/Universe.Core/Configuration/EngineConfiguration.cs @@ -26,6 +26,7 @@ public sealed record EngineConfiguration public BlackHoleGenerationSettings BlackHoles { get; init; } = new(); public GalaxySettings Galaxies { get; init; } = new(); public StarMotionBlurSettings StarMotionBlur { get; init; } = new(); + public Physics.SimulationSettings Simulation { get; init; } = new(); public double CellSizeMeters => CellSizeAu * UniverseScale.AstronomicalUnitMeters; public ulong Seed => UniverseSeed.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? ulong.Parse(UniverseSeed.AsSpan(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture) @@ -45,6 +46,7 @@ public sealed record EngineConfiguration public static EngineConfiguration Load(string path) { var settings = JsonSettings.Read(path); + (settings.Simulation ?? throw new InvalidDataException("config.json: simulation 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(); diff --git a/src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs b/src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs index 2fd3676..11a47be 100644 --- a/src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs +++ b/src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs @@ -55,7 +55,8 @@ public sealed class KnownSolarSystemCatalog VisualSettingsValidator.StarGlow(star.Glow, star.Id); stars.Add(new(stars.Count, star.Name, offset + Vector(star.PositionMeters), star.RadiusMeters, star.LuminositySolar, Color(star.Color), star.Seed ?? StableSeed(definition.Id + "/" + star.Id)) - { Id = star.Id, Glow = star.Glow }); + { Id = star.Id, Glow = star.Glow, MassKg = star.MassKg }); + Positive(star.MassKg, "star mass"); } var planets = new List(); foreach (var planet in definition.Planets) planets.Add(BuildPlanet(planet, definition.Id, stars, planets.Count, null, ids)); @@ -76,10 +77,21 @@ public sealed class KnownSolarSystemCatalog foreach (var planet in planets) { CheckBounds(planet.PositionMeters, System.Math.Max(planet.MaximumTerrainRadiusMeters, planet.Rings?.OuterRadiusMeters ?? 0), cellSize, planet.Id); + if (planet.Orbit is { } orbit) + { + var host = planet.ParentPlanetIndex is { } parent ? planets[parent] : null; + var star = stars[planet.HostStarIndex]; + var extent = orbit.SemiMajorAxisMeters * (1 + orbit.Eccentricity) + planet.MaximumTerrainRadiusMeters; + if (host?.Orbit is { } parentOrbit) extent += parentOrbit.SemiMajorAxisMeters * (1 + parentOrbit.Eccentricity); + CheckBounds(star.PositionMeters, extent, cellSize, planet.Id + " orbit"); + if (orbit.SemiMajorAxisMeters * (1 - orbit.Eccentricity) <= (host?.MaximumTerrainRadiusMeters ?? star.RadiusMeters) + planet.MaximumTerrainRadiusMeters) + throw new InvalidDataException($"'{planet.Id}': periapsis intersects its host body."); + } if (stars.Any(s => (s.PositionMeters - planet.PositionMeters).Length <= s.RadiusMeters + planet.MaximumTerrainRadiusMeters)) throw new InvalidDataException($"Planet '{planet.Id}' overlaps a star."); } - return new(coordinate, definition.Seed ?? StableSeed(definition.Id), stars, planets) { Id = definition.Id, Name = definition.Name }; + return new(coordinate, definition.Seed ?? StableSeed(definition.Id), stars, planets) + { Id = definition.Id, Name = definition.Name, EpochSecondsSinceJ2000 = (definition.EpochUtc - Physics.SimulationClock.J2000).TotalSeconds }; } private PlanetData BuildPlanet(PlanetDefinition p, string systemId, List stars, int index, PlanetData? parent, HashSet ids) @@ -110,7 +122,11 @@ public sealed class KnownSolarSystemCatalog throw new InvalidDataException($"'{p.Id}': gas planets use gas volume settings, not a terrain heightmap or terrain amplitude."); if (terrain.NoiseMask == TerrainNoiseMask.HeightmapLand && heightmap is null) throw new InvalidDataException($"'{p.Id}': heightmapLand noise mask requires a heightmap."); - var position = (parent?.PositionMeters ?? stars[host].PositionMeters) + OrbitPosition(p.Orbit); + Positive(p.MassKg, "planet mass"); + if (!double.IsFinite(p.RotationPeriodHours * 3600) || p.RotationPeriodHours == 0 || !double.IsFinite(p.PoleLongitudeDegrees)) + throw new InvalidDataException($"'{p.Id}': rotationPeriodHours must be finite and nonzero; pole longitude must be finite."); + var orbit = BuildOrbit(p.Orbit, (parent?.MassKg ?? stars[host].MassKg) + p.MassKg); + var position = (parent?.PositionMeters ?? stars[host].PositionMeters) + orbit.Position(0); var surfaceTexture = p.SurfaceTexture is null ? null : LoadColor(p.SurfaceTexture); var planetSeed = p.Seed ?? StableSeed(systemId + "/" + p.Id); var clouds = p.Clouds ?? CloudSettings.ForAtmosphere(planetSeed, air.HeightMeters, air.Density, p.SurfaceType); @@ -131,6 +147,9 @@ public sealed class KnownSolarSystemCatalog p.Orbit.PhaseDegrees * System.Math.PI / 180, host, p.Seed ?? StableSeed(systemId + "/" + p.Id)) { Id = p.Id, + MassKg = p.MassKg, + Orbit = orbit, + Rotation = new(p.RotationPeriodHours * 3600, p.AxialTiltDegrees, p.RotationDegrees, p.PoleLongitudeDegrees), Description = p.Description, ParentPlanetIndex = parent?.Index, SurfaceType = p.SurfaceType, @@ -145,7 +164,7 @@ public sealed class KnownSolarSystemCatalog OceanMask = p.Ocean is { Enabled: true, MaskFromSurfaceTexture: true } ? OceanSurface.CreateMask(surfaceTexture!) : null, AtmosphereDensity = air.Density, AtmosphereComposition = air.Composition, - Orientation = new(p.AxialTiltDegrees, p.RotationDegrees), + Orientation = new(p.AxialTiltDegrees, p.RotationDegrees, p.PoleLongitudeDegrees), Terrain = new() { HeightMap = heightmap, @@ -185,22 +204,27 @@ public sealed class KnownSolarSystemCatalog return result; } - private static DVector3 OrbitPosition(OrbitDefinition orbit) + private static Physics.KeplerOrbit BuildOrbit(OrbitDefinition orbit, double massKg) { Positive(orbit.SemiMajorAxisAu, "semi-major axis"); if (!Finite(orbit.Eccentricity, orbit.PhaseDegrees, orbit.InclinationDegrees, orbit.AscendingNodeDegrees, orbit.PeriapsisDegrees) || orbit.Eccentricity is < 0 or >= 1) throw new InvalidDataException("Only finite elliptical orbits with eccentricity in [0,1) are supported."); var phase = orbit.PhaseDegrees * System.Math.PI / 180; - var distance = orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters * (1 - orbit.Eccentricity * orbit.Eccentricity) - / (1 + orbit.Eccentricity * System.Math.Cos(phase)); - var angle = phase + orbit.PeriapsisDegrees * System.Math.PI / 180; + var axis = orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters; + var period = orbit.PeriodDays is { } days ? days * 86400 : Physics.KeplerOrbit.Period(axis, massKg); + Positive(period, "orbit period"); + if (orbit.MeanAnomalyDegrees is { } mean && !double.IsFinite(mean)) throw new InvalidDataException("meanAnomalyDegrees must be finite."); + var anomaly = orbit.MeanAnomalyDegrees is { } m ? m * System.Math.PI / 180 : Physics.KeplerOrbit.MeanAnomalyFromTrue(phase, orbit.Eccentricity); + var angle = orbit.PeriapsisDegrees * System.Math.PI / 180; var inclination = orbit.InclinationDegrees * System.Math.PI / 180; var node = orbit.AscendingNodeDegrees * System.Math.PI / 180; - var x = System.Math.Cos(angle) * distance; - var z = System.Math.Sin(angle) * distance; - var y = z * System.Math.Sin(inclination); - z *= System.Math.Cos(inclination); - return new(x * System.Math.Cos(node) - z * System.Math.Sin(node), y, x * System.Math.Sin(node) + z * System.Math.Cos(node)); + DVector3 Basis(double a) + { + var x = System.Math.Cos(a); var z = System.Math.Sin(a) * System.Math.Cos(inclination); + return new(x * System.Math.Cos(node) - z * System.Math.Sin(node), System.Math.Sin(a) * System.Math.Sin(inclination), + x * System.Math.Sin(node) + z * System.Math.Cos(node)); + } + return new(axis, orbit.Eccentricity, anomaly, period, Basis(angle), Basis(angle + System.Math.PI * .5)); } private string AssetPath(string relative) diff --git a/src/Universe.Core/Configuration/SolarSystemDefinitions.cs b/src/Universe.Core/Configuration/SolarSystemDefinitions.cs index dc33f24..184cf15 100644 --- a/src/Universe.Core/Configuration/SolarSystemDefinitions.cs +++ b/src/Universe.Core/Configuration/SolarSystemDefinitions.cs @@ -18,6 +18,7 @@ public sealed record SolarSystemDefinition public required string Name { get; init; } public required SectorDefinition Sector { get; init; } public ulong? Seed { get; init; } + public DateTimeOffset EpochUtc { get; init; } = Physics.SimulationClock.J2000; public double[] CenterOffsetMeters { get; init; } = [0, 0, 0]; public required List Stars { get; init; } public required List Planets { get; init; } @@ -31,6 +32,7 @@ public sealed record StarDefinition public double[] PositionMeters { get; init; } = [0, 0, 0]; public required double RadiusMeters { get; init; } public double LuminositySolar { get; init; } = 1; + public double MassKg { get; init; } = 1.98847e30; public float[] Color { get; init; } = [1, 0.94f, 0.85f]; public ulong? Seed { get; init; } } @@ -40,6 +42,8 @@ public sealed record OrbitDefinition public required double SemiMajorAxisAu { get; init; } public double Eccentricity { get; init; } public double PhaseDegrees { get; init; } + public double? MeanAnomalyDegrees { get; init; } + public double? PeriodDays { get; init; } public double InclinationDegrees { get; init; } public double AscendingNodeDegrees { get; init; } public double PeriapsisDegrees { get; init; } @@ -107,6 +111,9 @@ public sealed record PlanetDefinition public ulong? Seed { get; init; } public double AxialTiltDegrees { get; init; } public double RotationDegrees { get; init; } + public double MassKg { get; init; } = 5.97217e24; + public double RotationPeriodHours { get; init; } = 24; + public double PoleLongitudeDegrees { get; init; } public PlanetSurfaceType SurfaceType { get; init; } public GasVolumeSettings Gas { get; init; } = new(); public double CloudBandFrequency { get; init; } = 28; diff --git a/src/Universe.Core/Generation/CelestialBodies.cs b/src/Universe.Core/Generation/CelestialBodies.cs index 9641e85..36af1a0 100644 --- a/src/Universe.Core/Generation/CelestialBodies.cs +++ b/src/Universe.Core/Generation/CelestialBodies.cs @@ -1,5 +1,6 @@ using Universe.Core.Math; using Universe.Core.World; +using Universe.Core.Physics; namespace Universe.Core.Generation; @@ -13,6 +14,7 @@ public sealed record StarData( ulong Seed) { public string Id { get; init; } = $"star-{Index}"; + public double MassKg { get; init; } = 1.98847e30; public StarGlowSettings Glow { get; init; } = new(); public BlackHoleData? BlackHole { get; init; } public bool IsBlackHole => BlackHole is not null; @@ -33,6 +35,9 @@ public sealed record PlanetData( ulong Seed) { public string Id { get; init; } = $"planet-{Index}"; + public double MassKg { get; init; } = 5.97217e24; + public KeplerOrbit? Orbit { get; init; } + public AxialRotation? Rotation { get; init; } public string Description { get; init; } = string.Empty; public PlanetSurfaceType SurfaceType { get; init; } public bool IsGasPlanet => SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant; @@ -65,6 +70,7 @@ public sealed record StarSystemData( { public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}"; public string Name { get; init; } = "Proceduriellt solsystem"; + public double EpochSecondsSinceJ2000 { get; init; } public string? GalaxyId { get; init; } public string? NebulaId { get; init; } } diff --git a/src/Universe.Core/Generation/PlanetAppearance.cs b/src/Universe.Core/Generation/PlanetAppearance.cs index 833f443..5a55276 100644 --- a/src/Universe.Core/Generation/PlanetAppearance.cs +++ b/src/Universe.Core/Generation/PlanetAppearance.cs @@ -32,30 +32,36 @@ public sealed record PlanetRings(double InnerRadiusMeters, double OuterRadiusMet public RingPreviewSettings Preview { get; init; } = new(); } -/// Fixed body orientation. Terrain, texture UVs, ring planes and collision use this same transform. +/// Body orientation shared by terrain, texture UVs, ring planes and collision. public sealed class PlanetOrientation { public static readonly PlanetOrientation Identity = new(0, 0); private readonly double _sinTilt, _cosTilt, _sinSpin, _cosSpin; + private readonly double _sinPole, _cosPole; public double AxialTiltDegrees { get; } public double RotationDegrees { get; } + public double PoleLongitudeDegrees { get; } public Matrix4x4 BodyToWorld { get; } public Matrix4x4 WorldToBody { get; } - public PlanetOrientation(double axialTiltDegrees, double rotationDegrees) + public PlanetOrientation(double axialTiltDegrees, double rotationDegrees, double poleLongitudeDegrees = 0) { AxialTiltDegrees = axialTiltDegrees; RotationDegrees = rotationDegrees; + PoleLongitudeDegrees = poleLongitudeDegrees; var tilt = axialTiltDegrees * System.Math.PI / 180; var spin = rotationDegrees * System.Math.PI / 180; (_sinTilt, _cosTilt) = System.Math.SinCos(tilt); (_sinSpin, _cosSpin) = System.Math.SinCos(spin); - BodyToWorld = Matrix4x4.CreateRotationY((float)spin) * Matrix4x4.CreateRotationZ((float)tilt); + var pole = poleLongitudeDegrees * System.Math.PI / 180; + (_sinPole, _cosPole) = System.Math.SinCos(pole); + BodyToWorld = Matrix4x4.CreateRotationY((float)spin) * Matrix4x4.CreateRotationZ((float)tilt) * Matrix4x4.CreateRotationY((float)pole); WorldToBody = Matrix4x4.Transpose(BodyToWorld); } public DVector3 ToBody(DVector3 d) { + d = new(_cosPole * d.X - _sinPole * d.Z, d.Y, _sinPole * d.X + _cosPole * d.Z); var x = _cosTilt * d.X + _sinTilt * d.Y; var y = -_sinTilt * d.X + _cosTilt * d.Y; return new(_cosSpin * x - _sinSpin * d.Z, y, _sinSpin * x + _cosSpin * d.Z); @@ -65,6 +71,7 @@ public sealed class PlanetOrientation { var x = _cosSpin * d.X + _sinSpin * d.Z; var z = -_sinSpin * d.X + _cosSpin * d.Z; - return new(_cosTilt * x - _sinTilt * d.Y, _sinTilt * x + _cosTilt * d.Y, z); + var tx = _cosTilt * x - _sinTilt * d.Y; + return new(_cosPole * tx + _sinPole * z, _sinTilt * x + _cosTilt * d.Y, -_sinPole * tx + _cosPole * z); } } diff --git a/src/Universe.Core/Generation/UniverseGenerator.cs b/src/Universe.Core/Generation/UniverseGenerator.cs index e61c1ac..4f42e79 100644 --- a/src/Universe.Core/Generation/UniverseGenerator.cs +++ b/src/Universe.Core/Generation/UniverseGenerator.cs @@ -1,6 +1,7 @@ using System.Numerics; using Universe.Core.Math; using Universe.Core.World; +using Universe.Core.Physics; using Universe.Core.Configuration; namespace Universe.Core.Generation; @@ -68,7 +69,7 @@ public sealed class UniverseGenerator stars.Add(new(i, $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} Star {i + 1}", position, UniverseScale.SolarRadiusMeters * scale, System.Math.Pow(scale, 3.5), StarColors[random.NextInt(0, StarColors.Length)], DeterministicRandom.Mix(systemSeed + (ulong)i + 1)) - { Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}" }); + { Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:star:{i}", MassKg = 1.98847e30 * scale }); } if (hasBlackHole) @@ -77,7 +78,7 @@ public sealed class UniverseGenerator var hole = new BlackHoleData(mass, UnitVector(ref holeRandom), BlackHoles.DiskOuterRadiusInHorizons, BlackHoles.DiskBrightness, BlackHoles.RaySteps); stars[0] = stars[0] with { Name = $"{coordinate.X}:{coordinate.Y}:{coordinate.Z} Black Hole", RadiusMeters = hole.HorizonRadiusMeters, - LuminositySolar = .8, Color = new(1f, .73f, .44f), BlackHole = hole }; + LuminositySolar = .8, Color = new(1f, .73f, .44f), BlackHole = hole, MassKg = mass * 1.98847e30 }; } var planetCount = random.NextInt(5, 10); @@ -113,6 +114,9 @@ public sealed class UniverseGenerator orbitRadius, phase, 0, seed) { Id = $"procedural:{coordinate.X},{coordinate.Y},{coordinate.Z}:planet:{i}", + MassKg = 5.97217e24 * System.Math.Pow(radius / UniverseScale.EarthRadiusMeters, 3), + Orbit = MovingOrbit(position - stars[0].PositionMeters, orbitNormal, eccentricity, phase, stars[0].MassKg), + Rotation = new((10 + weather.NextDouble() * 40) * 3600, weather.NextDouble() * 60, weather.NextDouble() * 360), AtmosphereDensity = atmosphereDensity, Clouds = CloudSettings.ForAtmosphere(seed, radius * .018, atmosphereDensity, PlanetSurfaceType.Rocky) }); @@ -132,6 +136,9 @@ public sealed class UniverseGenerator moonOrbit, random.NextDouble() * System.Math.Tau, parent.HostStarIndex, moonSeed) { ParentPlanetIndex = parent.Index, Id = parent.Id + ":moon", AtmosphereDensity = .18, + MassKg = parent.MassKg * System.Math.Pow(moonRadius / parent.RadiusMeters, 3) * .6, + Orbit = MovingOrbit(moonDirection * moonOrbit, planeNormal, 0, 0, parent.MassKg), + Rotation = new(KeplerOrbit.Period(moonOrbit, parent.MassKg), 6, 0), Clouds = CloudSettings.ForAtmosphere(moonSeed, atmosphereHeight, .18, PlanetSurfaceType.Rocky) }); moonCount++; @@ -165,6 +172,17 @@ public sealed class UniverseGenerator return new(r * System.Math.Cos(phi), y, r * System.Math.Sin(phi)); } + private static KeplerOrbit MovingOrbit(DVector3 initial, DVector3 normal, double eccentricity, double phase, double mass) + { + var direction = initial.Normalized(); + var tangent = DVector3.Cross(normal, direction).Normalized(); + if (tangent.LengthSquared < .1) tangent = DVector3.Cross(new(1, 0, 0), direction).Normalized(); + var axis = initial.Length * (1 + eccentricity * System.Math.Cos(phase)) / (1 - eccentricity * eccentricity); + return new(axis, eccentricity, KeplerOrbit.MeanAnomalyFromTrue(phase, eccentricity), KeplerOrbit.Period(axis, mass), + direction * System.Math.Cos(phase) - tangent * System.Math.Sin(phase), + direction * System.Math.Sin(phase) + tangent * System.Math.Cos(phase)); + } + private static string RomanNumeral(int number) => number switch { 1 => "I", diff --git a/src/Universe.Core/Physics/CelestialSimulation.cs b/src/Universe.Core/Physics/CelestialSimulation.cs new file mode 100644 index 0000000..c801df3 --- /dev/null +++ b/src/Universe.Core/Physics/CelestialSimulation.cs @@ -0,0 +1,71 @@ +using Universe.Core.Generation; +using Universe.Core.Math; + +namespace Universe.Core.Physics; + +/// Analytic two-body orbit, referenced to its system epoch. U/V span the orbital plane. +public sealed record KeplerOrbit(double SemiMajorAxisMeters, double Eccentricity, double MeanAnomalyAtEpochRadians, + double PeriodSeconds, DVector3 U, DVector3 V) +{ + public const double GravitationalConstant = 6.67430e-11; + public static double Period(double semiMajorAxisMeters, double massKg) => + System.Math.Tau * System.Math.Sqrt(semiMajorAxisMeters * semiMajorAxisMeters * semiMajorAxisMeters / (GravitationalConstant * massKg)); + + public DVector3 Position(double seconds) + { + var m = System.Math.IEEERemainder(MeanAnomalyAtEpochRadians + System.Math.Tau * (seconds % PeriodSeconds) / PeriodSeconds, System.Math.Tau); + // Bisection is bounded and robust even for e very close to 1; Newton accelerates ordinary orbits. + var lo = -System.Math.PI; var hi = System.Math.PI; var e = m; + for (var i = 0; i < 64; i++) + { + var error = e - Eccentricity * System.Math.Sin(e) - m; + if (System.Math.Abs(error) < 1e-14) break; + if (error > 0) hi = e; else lo = e; + var next = e - error / (1 - Eccentricity * System.Math.Cos(e)); + e = next > lo && next < hi ? next : (lo + hi) * .5; + } + return U * (SemiMajorAxisMeters * (System.Math.Cos(e) - Eccentricity)) + + V * (SemiMajorAxisMeters * System.Math.Sqrt(1 - Eccentricity * Eccentricity) * System.Math.Sin(e)); + } + + public static double MeanAnomalyFromTrue(double trueAnomaly, double eccentricity) + { + var e = System.Math.Atan2(System.Math.Sqrt(1 - eccentricity * eccentricity) * System.Math.Sin(trueAnomaly), + eccentricity + System.Math.Cos(trueAnomaly)); + return e - eccentricity * System.Math.Sin(e); + } +} + +/// Positive period turns about the angular-momentum pole; tilt > 90 degrees gives retrograde spin. +public sealed record AxialRotation(double PeriodSeconds, double AxialTiltDegrees, double PhaseDegrees, double PoleLongitudeDegrees = 0) +{ + public PlanetOrientation At(double seconds) => new(AxialTiltDegrees, + PhaseDegrees - 360 * (seconds % PeriodSeconds) / PeriodSeconds, PoleLongitudeDegrees); +} + +public static class CelestialSimulation +{ + public static StarSystemData Evaluate(StarSystemData system, double secondsSinceJ2000) + { + if (system.Planets.Count == 0) return system; + var time = secondsSinceJ2000 - system.EpochSecondsSinceJ2000; + var planets = new PlanetData[system.Planets.Count]; + // Catalog/generator place parents before their satellites. + for (var i = 0; i < planets.Length; i++) + { + var p = system.Planets[i]; + var position = p.PositionMeters; + if (p.Orbit is { } orbit) + { + var center = p.ParentPlanetIndex is { } parent ? planets[parent].PositionMeters : system.Stars[p.HostStarIndex].PositionMeters; + position = center + orbit.Position(time); + } + planets[i] = p with + { + PositionMeters = position, + Orientation = p.Rotation?.At(time) ?? p.Orientation + }; + } + return system with { Planets = planets }; + } +} diff --git a/src/Universe.Core/Physics/SimulationClock.cs b/src/Universe.Core/Physics/SimulationClock.cs new file mode 100644 index 0000000..7852a28 --- /dev/null +++ b/src/Universe.Core/Physics/SimulationClock.cs @@ -0,0 +1,45 @@ +namespace Universe.Core.Physics; + +public sealed record SimulationSettings +{ + public DateTimeOffset StartUtc { get; init; } = SimulationClock.J2000; + public double TimeScale { get; init; } = 1; + + public void Validate() + { + if (!double.IsFinite(TimeScale) || TimeScale is < 0 or > 100) + throw new InvalidDataException("simulation.timeScale must be between 0 and 100."); + } +} + +/// World time only: camera input, player movement and UI use unscaled time. +public sealed class SimulationClock +{ + public static readonly DateTimeOffset J2000 = new(2000, 1, 1, 12, 0, 0, TimeSpan.Zero); + private double _timeScale; + public double SecondsSinceJ2000 { get; private set; } + public double ElapsedSeconds { get; private set; } + public DateTimeOffset Utc => J2000.AddSeconds(SecondsSinceJ2000); + public double TimeScale + { + get => _timeScale; + set => _timeScale = double.IsFinite(value) ? System.Math.Clamp(value, 0, 100) + : throw new ArgumentOutOfRangeException(nameof(value)); + } + + public SimulationClock(SimulationSettings settings) + { + settings.Validate(); + SecondsSinceJ2000 = (settings.StartUtc - J2000).TotalSeconds; + TimeScale = settings.TimeScale; + } + + public void Advance(double realSeconds) + { + if (!double.IsFinite(realSeconds) || realSeconds < 0) throw new ArgumentOutOfRangeException(nameof(realSeconds)); + // Do not clamp frame time: a slow frame must not slow the astronomical clock. + var step = realSeconds * TimeScale; + SecondsSinceJ2000 += step; + ElapsedSeconds += step; + } +} diff --git a/src/Universe.Core/World/UniverseStreamer.cs b/src/Universe.Core/World/UniverseStreamer.cs index 68cae7a..3d78ba1 100644 --- a/src/Universe.Core/World/UniverseStreamer.cs +++ b/src/Universe.Core/World/UniverseStreamer.cs @@ -1,4 +1,5 @@ using Universe.Core.Generation; +using Universe.Core.Physics; namespace Universe.Core.World; @@ -10,6 +11,10 @@ public sealed class UniverseStreamer(UniverseGenerator generator) private readonly Dictionary _cache = []; private SectorCoordinate? _nearBlock; private IReadOnlyList _nearSystems = []; + public double SimulationSeconds { get; set; } + public StarSystemData AtCurrentTime(StarSystemData system) => CelestialSimulation.Evaluate(system, SimulationSeconds); + public UniverseNeighborhood Refresh(UniverseNeighborhood neighborhood) => new( + AtCurrentTime(neighborhood.ActiveSystem), neighborhood.NearbySystems.Select(AtCurrentTime).ToArray()); public (StarSystemData System, StarData Hole)? FindNearestBlackHole(UniversePosition position) { @@ -67,7 +72,7 @@ public sealed class UniverseStreamer(UniverseGenerator generator) 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()); + return Refresh(new(active, _nearSystems.Where(s => s.Coordinate != activeCoordinate).ToArray())); } var nearby = new List(26); for (var z = -1; z <= 1; z++) @@ -88,7 +93,7 @@ public sealed class UniverseStreamer(UniverseGenerator generator) _cache.Remove(key); } - return new(active, nearby); + return Refresh(new(active, nearby)); } private StarSystemData GetOrCreate(SectorCoordinate coordinate) diff --git a/tests/Universe.Tests/CelestialSimulationTests.cs b/tests/Universe.Tests/CelestialSimulationTests.cs new file mode 100644 index 0000000..a51e70d --- /dev/null +++ b/tests/Universe.Tests/CelestialSimulationTests.cs @@ -0,0 +1,194 @@ +using System.Numerics; +using Universe.App.Graphics; +using Universe.App.Runtime; +using Universe.Core.Configuration; +using Universe.Core.Generation; +using Universe.Core.Math; +using Universe.Core.Physics; +using Universe.Core.World; + +namespace Universe.Tests; + +public sealed class CelestialSimulationTests +{ + private static readonly Lazy Sol = new(() => KnownSolarSystemCatalog.Load( + Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json")).GetById("sol")); + + [Fact] + public void ClockRunsAtRealTimePausesAndAcceleratesWithoutDroppingSlowFrames() + { + var clock = new SimulationClock(new()); + Assert.Equal(1, clock.TimeScale); + clock.Advance(2.5); + Assert.Equal(2.5, clock.SecondsSinceJ2000); + clock.TimeScale = 0; clock.Advance(120); + Assert.Equal(2.5, clock.ElapsedSeconds); + clock.TimeScale = 100; clock.Advance(60); + Assert.Equal(6002.5, clock.ElapsedSeconds); + Assert.Equal(SimulationClock.J2000.AddSeconds(6002.5), clock.Utc); + Assert.Throws(() => new SimulationClock(new() { TimeScale = 101 })); + Assert.Throws(() => clock.Advance(double.NaN)); + } + + [Theory] + [InlineData(0)] + [InlineData(.20563593)] + [InlineData(.9999)] + public void EllipseHasCorrectApsesAndClosesAfterAnOrbit(double eccentricity) + { + const double axis = 1e8, period = 100000; + var orbit = new KeplerOrbit(axis, eccentricity, 0, period, new(1, 0, 0), new(0, 0, 1)); + Assert.InRange(System.Math.Abs(orbit.Position(0).Length - axis * (1 - eccentricity)), 0, 1e-6); + Assert.InRange(System.Math.Abs(orbit.Position(period * .5).Length - axis * (1 + eccentricity)), 0, 1e-6); + Assert.InRange((orbit.Position(1200) - orbit.Position(period * 1000000 + 1200)).Length, 0, 1e-5); + Assert.InRange((orbit.Position(-1200) - orbit.Position(period - 1200)).Length, 0, 1e-5); + } + + [Fact] + public void MercuryAcceleratesAtPerihelionAndConservesKeplerEnergyAndAngularMomentum() + { + var orbit = Sol.Value.Planets.First(p => p.Id == "mercury").Orbit! with { MeanAnomalyAtEpochRadians = 0 }; + DVector3 Velocity(double t) => (orbit.Position(t + 1) - orbit.Position(t - 1)) * .5; + var periSpeed = Velocity(0).Length; + var apSpeed = Velocity(orbit.PeriodSeconds * .5).Length; + Assert.InRange(periSpeed / apSpeed, 1.517, 1.519); + var mu = System.Math.Pow(System.Math.Tau / orbit.PeriodSeconds, 2) * System.Math.Pow(orbit.SemiMajorAxisMeters, 3); + var energy = -mu / (2 * orbit.SemiMajorAxisMeters); + var h = DVector3.Cross(orbit.Position(0), Velocity(0)).Length; + foreach (var fraction in new[] { .01, .17, .4, .8 }) + { + var t = fraction * orbit.PeriodSeconds; + var v = Velocity(t); var r = orbit.Position(t); + Assert.InRange(System.Math.Abs((v.LengthSquared * .5 - mu / r.Length) / energy - 1), 0, 1e-7); + Assert.InRange(System.Math.Abs(DVector3.Cross(r, v).Length / h - 1), 0, 1e-7); + } + } + + [Fact] + public void EarthUsesSiderealDayAndYearAndMoonOrbitsTheMovingEarth() + { + var start = CelestialSimulation.Evaluate(Sol.Value, 0); + var hour = CelestialSimulation.Evaluate(Sol.Value, 3600); + var earth = start.Planets.First(p => p.Id == "earth"); + var later = hour.Planets[earth.Index]; + Assert.InRange(earth.Rotation!.PeriodSeconds, 86164, 86165); + Assert.InRange(earth.Orbit!.PeriodSeconds / 86400, 365.25, 365.26); + Assert.InRange((later.PositionMeters - earth.PositionMeters).Length, 106e6, 110e6); + var moon = start.Planets.First(p => p.Id == "moon"); + var day = CelestialSimulation.Evaluate(Sol.Value, 86400); + var relative = day.Planets[moon.Index].PositionMeters - day.Planets[earth.Index].PositionMeters; + Assert.InRange(relative.Length, 363e6, 406e6); + Assert.InRange((relative - moon.Orbit!.Position(86400)).Length, 0, .0001); + foreach (var p in start.Planets) + { + Assert.NotNull(p.Orbit); Assert.NotNull(p.Rotation); + Assert.Same(p.Terrain, hour.Planets[p.Index].Terrain); + } + } + + [Theory] + [InlineData("earth", true)] + [InlineData("venus", false)] + [InlineData("uranus", false)] + public void SpinDirectionMatchesProgradeOrRetrogradeWithoutDoubleNegation(string id, bool prograde) + { + var p = Sol.Value.Planets.First(p => p.Id == id); + var a = p.Rotation!.At(0).ToWorld(new(1, 0, 0)); + var b = p.Rotation.At(System.Math.Abs(p.Rotation.PeriodSeconds) / 100).ToWorld(new(1, 0, 0)); + var orbit = p.Orbit!; + var angular = DVector3.Dot(DVector3.Cross(a, b), DVector3.Cross(orbit.U, orbit.V)); + Assert.Equal(prograde, angular > 0); + } + + [Fact] + public void StreamedSystemUsesSameTimeAfterUnloadingAndDoesNotAccumulateOrbitError() + { + var generator = new UniverseGenerator(42); + var streamer = new UniverseStreamer(generator) { SimulationSeconds = 1234567 }; + var first = streamer.Load(new(0, 0, 0)); + streamer.Load(new(1000, 0, 0)); + var again = streamer.Load(new(0, 0, 0)); + Assert.Equal(first.ActiveSystem.Planets.Select(p => p.PositionMeters), again.ActiveSystem.Planets.Select(p => p.PositionMeters)); + var refreshed = streamer.Refresh(again); + Assert.Equal(again.ActiveSystem.Planets.Select(p => p.PositionMeters), refreshed.ActiveSystem.Planets.Select(p => p.PositionMeters)); + streamer.SimulationSeconds += 1000; + var moved = streamer.Refresh(again); + Assert.NotEqual(again.ActiveSystem.Planets[0].PositionMeters, moved.ActiveSystem.Planets[0].PositionMeters); + } + + [Fact] + public void GroundedPlayerFollowsOrbitAndTerrainRotationWithoutLosingClearance() + { + const double cell = 10000000; + var planet = new PlanetData(0, "Test", DVector3.Zero, 10000, 150, 0, Vector3.One, Vector3.Zero, 1e6, 0, 0, 20) + { + Orbit = new(1e6, .1, 0, 86400, new(1, 0, 0), new(0, 0, 1)), + Rotation = new(1000, 23.4, 30) + }; + var basis = new StarSystemData(new(0, 0, 0), 1, [new(0, "Sun", DVector3.Zero, 1000, 1, Vector3.One, 1)], [planet]); + var before = new UniverseNeighborhood(CelestialSimulation.Evaluate(basis, 0), []); + var p = before.ActiveSystem.Planets[0]; + var center = UniversePosition.AtCellCenter(basis.Coordinate, cell); + var direction = p.Orientation.ToWorld(new DVector3(.6, .8, 0)); + var body = center.Add(p.PositionMeters, cell); + var player = new PlayerController(body.Add(direction * (TerrainHeight.Sample(p, direction) + 1.7), cell), + p.Orientation.ToWorld(new(0, 0, 1)), direction, cell, before); + player.Update(.01, 0, 0, false, false, before); + Assert.True(player.Grounded); + var local = p.Orientation.ToBody(player.Position.RelativeTo(body, cell)); + for (var i = 1; i <= 20; i++) + { + var current = new UniverseNeighborhood(CelestialSimulation.Evaluate(basis, i * 10), []); + player.ApplyCelestialMotion(before, current); + p = current.ActiveSystem.Planets[0]; + var offset = player.Position.RelativeTo(center.Add(p.PositionMeters, cell), cell); + Assert.InRange((p.Orientation.ToBody(offset) - local).Length, 0, .0001); + Assert.InRange(offset.Length - TerrainHeight.Sample(p, offset.Normalized()), 1.6999, 1.7001); + Assert.InRange(DVector3.Dot(player.Up, offset.Normalized()), .999999, 1.000001); + before = current; + } + player.Update(.01, 0, 0, true, false, before); + Assert.False(player.Grounded); + var jumpBody = center.Add(before.ActiveSystem.Planets[0].PositionMeters, cell); + var jumpLocal = before.ActiveSystem.Planets[0].Orientation.ToBody(player.Position.RelativeTo(jumpBody, cell)); + var whileJumping = new UniverseNeighborhood(CelestialSimulation.Evaluate(basis, 210), []); + player.ApplyCelestialMotion(before, whileJumping); + p = whileJumping.ActiveSystem.Planets[0]; + var carriedJump = p.Orientation.ToBody(player.Position.RelativeTo(center.Add(p.PositionMeters, cell), cell)); + Assert.InRange((carriedJump - jumpLocal).Length, 0, .0001); + } + + [Fact] + public void DebugTimeSliderSupportsPauseRealTimeAndMaximum() + { + Assert.Equal(0d, PauseMenu.TimeScaleHit(1010, 390, 1440, 900)); + Assert.Equal(100d, PauseMenu.TimeScaleHit(1330, 390, 1440, 900)); + Assert.Equal(1d, PauseMenu.TimeScaleHit(1129, 469, 1440, 900)); + Assert.Null(PauseMenu.TimeScaleHit(720, 450, 1440, 900)); + } + + [Fact] + public void FocusFlightTracksMovingEarthAtMaximumTimeScale() + { + var cell = UniverseScale.DefaultCellSizeMeters; + var world = new UniverseNeighborhood(CelestialSimulation.Evaluate(Sol.Value, 0), []); + var camera = new FlyCamera(UniversePosition.AtCellCenter(new(0, 0, 0), cell), cell); + camera.SetInitialView(world.ActiveSystem, new StartupConfiguration { PlanetId = "earth", DistanceInRadii = 5 }); + camera.RequestFocus([world.ActiveSystem], "earth"); + var time = 0d; + for (var i = 0; i < 60 && camera.IsFocusing; i++) + { + time += 10; + var next = new UniverseNeighborhood(CelestialSimulation.Evaluate(Sol.Value, time), []); + camera.ApplyCelestialMotion(world, next, time); + camera.Update(.1, DVector3.Zero, next); + world = next; + } + Assert.False(camera.IsFocusing); + var earth = world.ActiveSystem.Planets.Single(p => p.Id == "earth"); + var body = UniversePosition.AtCellCenter(new(0, 0, 0), cell).Add(earth.PositionMeters, cell); + var toward = body.RelativeTo(camera.Position, cell); + Assert.InRange(toward.Length / earth.RadiusMeters, 1.1, 10); + Assert.InRange(DVector3.Dot(toward.Normalized(), camera.Forward), .999, 1.001); + } +}