init
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
.dotnet-cli/
|
||||||
|
.vs/
|
||||||
|
TestResults/
|
||||||
|
*.log
|
||||||
|
/artifacts
|
||||||
BIN
Content/Clouds/earth-clouds.png
Normal file
|
After Width: | Height: | Size: 38 MiB |
BIN
Content/Gravel/rocks_ground_05/Diffuse.jpg
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
Content/Gravel/rocks_ground_05/Rough.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
Content/Gravel/rocks_ground_05/nor_gl.jpg
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
Content/Heightdata/Copernicus/alps_n46_e007.f32
Normal file
28
Content/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Markmaterial och höjddata
|
||||||
|
|
||||||
|
`materials.json` anger tillgängliga material och seedade urvalslistor för fasta planeter. Varje material har färg (`color`), OpenGL-normal (`normal`) och ytjämnhet (`roughness`). Sökvägarna är relativa till `Content/`. Materialen är från [Poly Haven](https://polyhaven.com/textures) och är [CC0](https://polyhaven.com/license):
|
||||||
|
|
||||||
|
- [Leafy Grass](https://polyhaven.com/a/leafy_grass), Charlotte Baglioni, `Vegetations/leafy_grass/`.
|
||||||
|
- [Rock Ground](https://polyhaven.com/a/rock_ground), Rob Tuytel, `Rocks/rock_ground/`.
|
||||||
|
- [Rocks Ground 05](https://polyhaven.com/a/rocks_ground_05), Rob Tuytel, `Gravel/rocks_ground_05/`.
|
||||||
|
- [Coast Sand 02](https://polyhaven.com/a/coast_sand_02), `Sand/coast_sand_02/`.
|
||||||
|
|
||||||
|
Katalogen används automatiskt av fasta planeter. Per planet kan `materials` ange `enabled`, `flatMaterial`, `slopeMaterial`, `scaleMeters`, `fadeDistanceMeters` och `strength`. Om ett material-ID saknas väljs det deterministiskt från katalogens listor med planetens seed. `enabled: false` stänger av markmaterialet. Materialen projiceras i tre riktningar, tonas in nära kameran och blandas efter terrängens lutning. Hav och gasjättar använder sina egna material. Färgkartan är sRGB; normal och roughness är linjära.
|
||||||
|
|
||||||
|
## Alpernas höjdruta
|
||||||
|
|
||||||
|
`Heightdata/Copernicus/Copernicus_DSM_COG_10_N46_00_E007_00_DEM.tif` är den ursprungliga Copernicus GLO-30-rutan för 46–47°N, 7–8°E. `alps_n46_e007.f32` är samma 3 600 × 3 600 höjder i meter, norra raden först, som little-endian float32. Jordens `terrain.regionalHeight` anger fil, dimensioner, geografiska gränser och kantövergång. Rutan ersätter den globala höjdens värde innanför området; procedurbrus läggs fortfarande ovanpå. Renderad terräng och kollision använder samma data. Utanför rutan används den tidigare globala kartan. Övriga planeter får inte geografiska jorddata automatiskt.
|
||||||
|
|
||||||
|
Höjdvärdena är en digital ytmodell, så vegetation och byggnader kan ingå. Denna enda ruta täcker inte hela jorden och ingen automatisk strömning av globala höjddata ingår ännu. För att lägga till en annan ruta krävs motsvarande float32-fil och rätt geografiska koordinater i planetens JSON. För närvarande kan en regional ruta anges per planet.
|
||||||
|
|
||||||
|
Produced using Copernicus WorldDEM-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved. Källa: [Copernicus DEM på AWS](https://registry.opendata.aws/copernicus-dem/), hämtad 2026-10-29. Se även [Copernicus villkor och källangivelse](https://dataspace.copernicus.eu/explore-data/data-collections/copernicus-contributing-missions/collections-description/COP-DEM).
|
||||||
|
|
||||||
|
`download-content.ps1` hämtar originalfilerna och verifierar kontrollsummor. Float32-filen är konverterad från GeoTIFF med oförändrade höjdvärden; den ingår separat eftersom motorn inte läser GeoTIFF direkt.
|
||||||
|
|
||||||
|
## Molntäthetskartor
|
||||||
|
|
||||||
|
`Clouds/earth-clouds.png` är den equirektangulära molnkarta som användaren tillhandahöll för jorden. Den läses som en enkelkanals gråskalebild: svart betyder klar himmel, vitt betyder tät molntäckning och grått ger en mjuk övergång. Jordens planetdefinition anger kartan i `clouds.densityTexturePath`. Kartor för andra planeter kan läggas i samma mapp och anges på motsvarande sätt. Sökvägen i planetens JSON är relativ till `Content/`; `densityTextureStrength` blandar kartan med det proceduriella vädret.
|
||||||
|
|
||||||
|
För att starta med kameran riktad mot rutan, sätt `startup.longitudeDegrees` till `-7.5` och `startup.latitudeDegrees` till `46.5` i `config.json`, och flyg sedan mot ytan. Den negativa longituden följer motorns befintliga texturkoordinater.
|
||||||
|
|
||||||
|
En GPU-kontroll av höjdrutan och ett materialtest kan köras med `dotnet run -c Release --project src/Universe.App -- --terrain-report --validate-graphics`. Diagnostiken använder en artificiell solposition och stänger av moln och hav för att isolera terrängmaterialen.
|
||||||
BIN
Content/Rocks/rock_ground/Diffuse.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
Content/Rocks/rock_ground/Rough.jpg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
Content/Rocks/rock_ground/nor_gl.jpg
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
Content/Sand/coast_sand_02/Diffuse.jpg
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
Content/Sand/coast_sand_02/Rough.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
Content/Sand/coast_sand_02/nor_gl.jpg
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
Content/Vegetations/leafy_grass/Diffuse.jpg
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
Content/Vegetations/leafy_grass/Rough.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
Content/Vegetations/leafy_grass/nor_gl.jpg
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
30
Content/download-content.ps1
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = $PSScriptRoot
|
||||||
|
$assets = @(
|
||||||
|
@{ Id = 'leafy_grass'; Folder = 'Vegetations' },
|
||||||
|
@{ Id = 'rock_ground'; Folder = 'Rocks' },
|
||||||
|
@{ Id = 'rocks_ground_05'; Folder = 'Gravel' },
|
||||||
|
@{ Id = 'coast_sand_02'; Folder = 'Sand' }
|
||||||
|
)
|
||||||
|
foreach ($asset in $assets) {
|
||||||
|
$dir = Join-Path $root "$($asset.Folder)/$($asset.Id)"
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
$files = Invoke-RestMethod "https://api.polyhaven.com/files/$($asset.Id)"
|
||||||
|
foreach ($kind in @('Diffuse', 'nor_gl', 'Rough')) {
|
||||||
|
$file = $files.$kind.'2k'.jpg
|
||||||
|
if (-not $file) { throw "Missing 2K JPEG $kind for $($asset.Id)" }
|
||||||
|
$destination = Join-Path $dir "$kind.jpg"
|
||||||
|
if (-not (Test-Path -LiteralPath $destination) -or (Get-FileHash -Algorithm MD5 -LiteralPath $destination).Hash.ToLowerInvariant() -ne $file.md5) {
|
||||||
|
Invoke-WebRequest -Uri $file.url -OutFile $destination
|
||||||
|
}
|
||||||
|
if ((Get-FileHash -Algorithm MD5 -LiteralPath $destination).Hash.ToLowerInvariant() -ne $file.md5) { throw "Checksum mismatch: $destination" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dir = Join-Path $root 'Heightdata/Copernicus'
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
$tile = 'Copernicus_DSM_COG_10_N46_00_E007_00_DEM'
|
||||||
|
$destination = Join-Path $dir "$tile.tif"
|
||||||
|
if (-not (Test-Path -LiteralPath $destination)) {
|
||||||
|
Invoke-WebRequest -Uri "https://copernicus-dem-30m.s3.amazonaws.com/$tile/$tile.tif" -OutFile $destination
|
||||||
|
}
|
||||||
|
if ((Get-FileHash -Algorithm MD5 -LiteralPath $destination).Hash.ToLowerInvariant() -ne 'd75ca33280e101b7e83b2fbef81e853b') { throw "Checksum mismatch: $destination" }
|
||||||
11
Content/materials.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"materials": [
|
||||||
|
{ "id": "leafy-grass", "color": "Vegetations/leafy_grass/Diffuse.jpg", "normal": "Vegetations/leafy_grass/nor_gl.jpg", "roughness": "Vegetations/leafy_grass/Rough.jpg" },
|
||||||
|
{ "id": "rock-ground", "color": "Rocks/rock_ground/Diffuse.jpg", "normal": "Rocks/rock_ground/nor_gl.jpg", "roughness": "Rocks/rock_ground/Rough.jpg" },
|
||||||
|
{ "id": "gravel", "color": "Gravel/rocks_ground_05/Diffuse.jpg", "normal": "Gravel/rocks_ground_05/nor_gl.jpg", "roughness": "Gravel/rocks_ground_05/Rough.jpg" },
|
||||||
|
{ "id": "sand", "color": "Sand/coast_sand_02/Diffuse.jpg", "normal": "Sand/coast_sand_02/nor_gl.jpg", "roughness": "Sand/coast_sand_02/Rough.jpg" }
|
||||||
|
],
|
||||||
|
"terrestrialFlat": ["leafy-grass"],
|
||||||
|
"rockyFlat": ["gravel", "sand", "rock-ground"],
|
||||||
|
"slopes": ["rock-ground", "gravel"]
|
||||||
|
}
|
||||||
300
KnownSolarSystem/README.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Kända solsystem
|
||||||
|
|
||||||
|
`solar-systems.json` innehåller solsystemen som ersätter proceduriell generering i sina tilldelade gridceller. `sol` ligger i `(0,0,0)`. Övriga celler fortsätter genereras från universums seed.
|
||||||
|
|
||||||
|
## Planet-id:n
|
||||||
|
|
||||||
|
| Id | Namn |
|
||||||
|
| --- | --- |
|
||||||
|
| `mercury` | Merkurius |
|
||||||
|
| `venus` | Venus |
|
||||||
|
| `earth` | Jorden |
|
||||||
|
| `mars` | Mars |
|
||||||
|
| `jupiter` | Jupiter |
|
||||||
|
| `saturn` | Saturnus |
|
||||||
|
| `uranus` | Uranus |
|
||||||
|
| `neptune` | Neptunus |
|
||||||
|
|
||||||
|
`sun` är Solen. `moon` definieras som en satellit under Jordens `moons` och räknas inte som en av de åtta planeterna. Id:n är stabila och unika inom respektive solsystem, oberoende av ordningen i JSON-listan.
|
||||||
|
|
||||||
|
## Startkamera
|
||||||
|
|
||||||
|
Projektets `../config.json` väljer startmålet:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"startup": {
|
||||||
|
"systemId": "sol",
|
||||||
|
"planetId": "earth",
|
||||||
|
"planetHotkeys": ["mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune"],
|
||||||
|
"distanceInRadii": 3.0,
|
||||||
|
"longitudeDegrees": 20,
|
||||||
|
"latitudeDegrees": 20,
|
||||||
|
"speedMetersPerSecond": 200000,
|
||||||
|
"fieldOfViewDegrees": 67.65
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Byt `planetId` för att starta framför en annan planet. `planetHotkeys` mappar tangenterna **1–9** i ordning till planet-id:n; exempelvis går **1** till Merkurius och **6** till Saturnus. Listan får innehålla högst nio id:n som finns i det valda startsystemet. Latitud och longitud avser planetens egna texturkoordinater. `distanceInRadii` är avståndet från planetens centrum.
|
||||||
|
|
||||||
|
**F2** ordnar en solbelyst ytnära vy, **F3** visar närmaste stjärna och **F4** flyger till närmaste ringsystem.
|
||||||
|
|
||||||
|
Tryck **Q** för att flyga mjukt till den närmaste planeten från kamerans nuvarande position.
|
||||||
|
|
||||||
|
Ändringar i JSON och kartor läses vid appstart. Kör `dotnet run --configuration Release --project src/Universe.App/Universe.App.csproj` från projektroten efter ändringar; byggsteget kopierar konfiguration och kartor till körmappen. Vid direktstart av exe-filen läses filerna bredvid exe-filen. De följer även med `dotnet publish`.
|
||||||
|
|
||||||
|
## Parametrar
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Jordens heightmap och noise
|
||||||
|
|
||||||
|
Jordens inställningar kombinerar kartan med finare terräng:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"terrain": {
|
||||||
|
"heightmap": "Textures/earth-height.jpg",
|
||||||
|
"heightmapMinMeters": 0,
|
||||||
|
"heightmapMaxMeters": 8848,
|
||||||
|
"heightmapGamma": 1.0,
|
||||||
|
"proceduralNoiseAmplitudeMeters": 120,
|
||||||
|
"proceduralNoiseFrequency": 24,
|
||||||
|
"noiseMask": "heightmapLand",
|
||||||
|
"landThreshold": 0.02,
|
||||||
|
"landBlend": 0.04
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Höjden beräknas som:
|
||||||
|
|
||||||
|
`radie + kartans höjd + proceduriell noise × amplitud × landmask`
|
||||||
|
|
||||||
|
Svart på kartan motsvarar `heightmapMinMeters`, vitt motsvarar `heightmapMaxMeters`. `heightmapGamma` formar fördelningen mellan dem. Noisens frekvens styr detaljstorleken; amplituden styr höjdskillnaderna. `heightmapLand` tonar in noise över svarta havsområden enligt tröskel och övergångsbredd. `everywhere` applicerar noise överallt.
|
||||||
|
|
||||||
|
- Bara heightmap: sätt `proceduralNoiseAmplitudeMeters` till `0`.
|
||||||
|
- Bara noise: utelämna `heightmap` och använd `noiseMask: "everywhere"`.
|
||||||
|
- Båda: behåll båda inställningarna som i exemplet ovan.
|
||||||
|
|
||||||
|
Meshgenerering, kamera-kollision, marknormaler och bergsskuggor samplar samma kombinerade höjdfunktion. GPU-kontrollen `--validate-graphics` jämför också Jordens heightmap + noise med CPU-funktionen.
|
||||||
|
|
||||||
|
## Hav och vatten
|
||||||
|
|
||||||
|
Jordens `ocean`-block aktiverar ett eget vattenpass på planetens havsnivå. Tryck **F6** för en mjuk flygning till en solbelyst havsvy ungefär sex meter över vattnet. Kameran kan flyga under ytan och stannar vid havsbotten.
|
||||||
|
|
||||||
|
| Fält i `ocean` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled` | Aktivera havet; utelämnat block ger inget vatten |
|
||||||
|
| `maskFromSurfaceTexture` | Använd färgkartans blå vattenområden som kustmask; vita isområden och land förblir torra. `false` fyller terräng under havsnivån utan färgmask |
|
||||||
|
| `seaLevelMeters`, `depthMeters` | Havsnivå relativt planetradien och bottennivå i maskerade hav |
|
||||||
|
| `waveAmplitudeMeters`, `waveLengthMeters` | Den största vågens amplitud och våglängd; ytterligare elva komponenter ger finare vågor |
|
||||||
|
| `windDirectionDegrees`, `waveSpeed`, `choppiness` | Vågriktning, animationshastighet och hur spetsiga vågkammarna är |
|
||||||
|
| `roughness`, `reflectionStrength`, `refractionStrength` | Solglittrets bredd, reflektionernas styrka och brytningens skärmförskjutning |
|
||||||
|
| `deepColor`, `shallowColor`, `absorption` | Linjära RGB-färger för djup/grunt vatten och absorption per meter för rött, grönt och blått ljus |
|
||||||
|
| `foamStrength` | Skum på vågkammar och vid grunda stränder |
|
||||||
|
|
||||||
|
Vågspektrumet består av Gerstner-komponenter som filtreras efter pixelstorleken. Ytan söks med strålar i ett lokalt koordinatsystem förankrat i dubbelprecision, så samma hav fungerar från rymden till våghöjd. Reflektionerna kombinerar en analytisk atmosfärshimmel med skärmreflektioner av synlig terräng; objekt utanför bilden använder himlen som reserv. Refraktionen använder scenens färg och djup från ett separat framebuffer. Havet simulerar ingen fluiddynamik, fartygskölvatten eller bestående skumtransport.
|
||||||
|
|
||||||
|
Tekniken är inspirerad av principerna i [Crests beskrivning](https://github.com/JJarvis89/crest-oceanrender#the-tech), med en egen implementation för OpenGL 3.3. Unity-paketet ingår inte som ett beroende.
|
||||||
|
|
||||||
|
Skapa bilder och kontrollera animation, giltiga GPU-pixlar samt undervattensvyn med:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run -c Release --project src/Universe.App -- --ocean-report --validate-graphics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volymetriska 3D-moln
|
||||||
|
|
||||||
|
Jordens `clouds`-block definierar ett sfäriskt molnlager mellan 2 100 och 6 400 meter. Molnen går att flyga genom och syns även från rymden. **F7** ger en vy strax ovanför molntopparna; **F6** visar dem underifrån över havet. Space/Ctrl flyttar kameran genom lagret.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"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,
|
||||||
|
"windSpeedMetersPerSecond": 18,
|
||||||
|
"windDirectionDegrees": 25,
|
||||||
|
"sunsetStrength": 1.4,
|
||||||
|
"viewSteps": 48,
|
||||||
|
"lightSteps": 4,
|
||||||
|
"resolutionScale": 0.4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Fält i `clouds` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled`, valfri `seed` | Aktivering och valfri separat väderseed; annars används planetens seed |
|
||||||
|
| `baseAltitudeMeters`, `thicknessMeters` | Molnbas över planetradien och lagrets tjocklek; hela lagret ska ligga inom atmosfären |
|
||||||
|
| `coverage`, `density` | Täckning och optisk täthet; båda kan sättas till `0` för en klar himmel |
|
||||||
|
| `densityTexturePath`, `densityTextureStrength` | Valfri equirektangulär gråskalekarta relativ till `Content/`; svart tar bort moln, vitt behåller full täckning och grått ger mjukare kanter. Styr hur starkt kartan formar det proceduriella molnfältet |
|
||||||
|
| `shapeScaleMeters`, `detailScaleMeters`, `erosion` | Större molnform, finare kanter och hur mycket mindre noise bryter upp kanterna |
|
||||||
|
| `cloudType` | Profil från låga skiktmoln (`0`) till höga stackmoln (`1`) |
|
||||||
|
| `windSpeedMetersPerSecond`, `windDirectionDegrees` | Vind i planetens koordinatsystem; fältet är stabilt när kameran rör sig |
|
||||||
|
| `extinctionPerMeter`, `scatteringAlbedo` | Ljusdämpning och hur stor andel av ljuset som sprids i molnet |
|
||||||
|
| `anisotropy`, `silverLiningStrength` | Framåtspridning och ljusa molnkanter mot solen |
|
||||||
|
| `ambientStrength`, `sunlightStrength` | Indirekt himmelsljus och direkt solljus |
|
||||||
|
| `sunsetStrength` | Atmosfärens färgfiltrering: blått ljus dämpas mer på den långa vägen vid låg sol |
|
||||||
|
| `shadowStrength` | Molnskuggornas styrka på mark och hav |
|
||||||
|
| `viewSteps`, `lightSteps` | 16–192 steg genom molnet och 2–16 mot varje sol |
|
||||||
|
| `resolutionScale` | Molnpassets upplösning relativt fönstret, 0,25–1; `0.5` ger halv bredd/höjd med djupstyrd uppskalning |
|
||||||
|
| `maxViewDistanceMeters` | Maximal sträcka genom varje molnsegment; även omloppsvyer stöds |
|
||||||
|
|
||||||
|
Utelämnat `clouds`-block ger seedat molnväder som anpassas till `atmosphere.heightMeters` och `atmosphere.density`. Tunn eller obefintlig atmosfär ger inga moln, medan tätare atmosfärer i genomsnitt ger större täckning och täthet. Samma seed ger samma molninställningar. För att uttryckligen stänga av moln, använd `"clouds": { "enabled": false }`. Fasta planeter använder detta lager; gasjättars molnband hör till deras `gas`-volym.
|
||||||
|
|
||||||
|
Rendering och skuggor använder samma 3D-fält och valfria 2D-molntäthetskarta. Jordens karta `Content/Clouds/earth-clouds.png` styr molnens geografiska täckning även på avstånd; den procedurella volymen ger molnen höjd, form och detaljer. Ljuset samplas genom molnvolymen mot varje sol, och en atmosfärsmodell dämpar blått ljus mer än rött vid horisonten. Solglöden dämpas av molnens transmittans. Havet har en billigare spegling av samma molnfält. Väder och multipel ljusspridning är visuella realtidsmodeller. Principerna för strålmarschering och ett separat molnpass är inspirerade av [RealtimeVolumetricCloudRenderer](https://github.com/OfenPower/RealtimeVolumetricCloudRenderer), med egen GLSL-kod för den sfäriska världen.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run -c Release --project src/Universe.App -- --cloud-report --validate-graphics
|
||||||
|
```
|
||||||
|
|
||||||
|
Rapporten sparar bilder under, över och inne i molnen samt från omloppsbana. Den kontrollerar giltig transmittans, vindrörelse och att molnens renderade ljus blir varmare när solen sänks till horisonten.
|
||||||
|
|
||||||
|
## Kartfiler
|
||||||
|
|
||||||
|
Appen använder `Textures/earth-color.jpg` (**6880 × 3440**) och `Textures/earth-height.jpg` (**1280 × 640**). De bifogade PNG-förhandsbilderna är bara **168 × 159** pixlar. Höjdkartans Himalaya toppar vid ungefär 239/255, vilket blir cirka **8 300 m** med maxhöjden 8 848 m. I den lilla PNG-förhandsbilden når samma område bara ungefär 94/255, alltså cirka **3 300 m**. Det var därför bergen såg låga ut: JSON pekade på förhandsbilden i stället för den detaljerade höjdkartan. Den högupplösta kartan ger också betydligt bättre relief och färgdetaljer nära marken.
|
||||||
|
|
||||||
|
Byt gärna till andra kartor och ändra sökvägarna i JSON. Kartorna tolkas som equirektangulära: norr upptill, söder nedtill, longitud −180° till +180° från vänster till höger. Planetens längdkoordinat spegelvänds vid mappningen så att kontinenterna visas rättvända i den här kamerans koordinatsystem; både färg och höjd använder samma projektion. Albedo och heightmap kan ha olika pixelupplösning men måste använda samma geografiska projektion. Höjdkartor läses som 8-bitars gråvärden ur bildens röda kanal; texturer färghanteras som sRGB.
|
||||||
|
|
||||||
|
Alla kartsökvägar är relativa till denna mapp och måste stanna inom den. Inga absoluta sökvägar till urklippsfiler behövs vid körning.
|
||||||
|
|
||||||
|
## Datakällor och modell
|
||||||
|
|
||||||
|
Radier, ungefärliga banavstånd och axellutningar utgår från [NASA Planetary Fact Sheet](https://nssdc.gsfc.nasa.gov/planetary/factsheet/) och [NASA Planet Compare](https://solarsystem.nasa.gov/planet-compare/). Solens radie följer [NASA Sun Fact Sheet](https://nssdc.gsfc.nasa.gov/planetary/factsheet/sunfact.html). De viktigaste ringradierna och Cassinidelningen följer [NASA Saturnian Rings Fact Sheet](https://nssdc.gsfc.nasa.gov/planetary/factsheet/satringfact.html).
|
||||||
|
|
||||||
|
Stenplaneterna är sfäriska approximationer; gasplaneterna är tillplattade volymer. Banornas illustrativa faser är inte aktuella efemerider. Moln, atmosfärsfärger och ringarnas optiska egenskaper är visuella modeller; klimat, växthuseffekt, vindar och magnetfält simuleras inte fysikaliskt. Endast Jorden har geografiska kartor i denna första katalog; övriga ytor är proceduriella material.
|
||||||
|
|
||||||
|
## Gasplaneter som volym
|
||||||
|
|
||||||
|
`surfaceType: "gasGiant"` och `"iceGiant"` använder **ingen planetmesh eller fast kollisionsyta**. Shadern integrerar färgad gas längs varje kamerastråle och skuggar gasen med ljusstrålar mot varje sol. Kameran kan flyga in i och genom gasen. Inuti tätnar dimman och skymmer omgivningen. Solens riktning, ringskuggor och andra planeters förmörkelser påverkar volymen. `cloudCovered` är fortfarande en fast planet med molntäcke, exempelvis Venus.
|
||||||
|
|
||||||
|
Saturnus har ett komplett `gas`-block i JSON. Övriga gasplaneter kan använda samma parametrar och egna färger. Utelämnade värden får standardvärden från `GasVolumeSettings`.
|
||||||
|
|
||||||
|
| Fält i `gas` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `outerRadiusScale`, `polarRadiusScale` | Volymens yttre radie och tillplattning vid polerna, relativt `radiusMeters` |
|
||||||
|
| `density`, `scaleHeight` | Optisk täthet och hur snabbt den ökar inåt; skalhöjden anges som andel av radien |
|
||||||
|
| `viewSteps`, `lightSteps` | Integrationssteg för kamera respektive solljus, 16–192 och 2–16 |
|
||||||
|
| `sampleDistribution`, `stopTransmittance` | Koncentration av prover nära volymens framkant och gräns för tidigt avslut |
|
||||||
|
| `scatteringAlbedo`, `lightExtinction`, `anisotropy` | Andel spritt ljus, effektiv utsläckning mot solen och spridningens riktning |
|
||||||
|
| `sunlightStrength`, `ambientStrength` | Direktljus och svagt omgivningsljus |
|
||||||
|
| `bandColor`, `polarColor`, `hazeColor` | RGB-färger för molnband, polarområden och dis; basfärgen är planetens `surfaceColor` |
|
||||||
|
| `bandContrast`, `fineBandFrequency`, `fineBandContrast` | Variation i de breda och fina latitudbanden |
|
||||||
|
| `cloudTurbulence`, `detailStrength`, `noiseScale`, `windSpeed` | Bandens turbulens, finstruktur, noisefrekvens och animation |
|
||||||
|
|
||||||
|
Planetens `cloudBandFrequency` och `stormStrength` styr fortsatt de stora banden och stormen. Gasplaneter måste ha `proceduralNoiseAmplitudeMeters: 0` och sakna heightmap. `gas` ersätter deras vanliga atmosfärspass; `atmosphere` beskriver fortfarande sammansättning och ger en reservfärg för dis.
|
||||||
|
|
||||||
|
Volymen använder Beer–Lambert-transmittans och enkel spridning med en effektiv kompensation genom `lightExtinction`. Det är en realtidsmodell, inte en fullständig simulering av multipel spridning eller gasdynamik. Principerna beskrivs i [GPU Gems: Volume Rendering Techniques](https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-39-volume-rendering-techniques).
|
||||||
|
|
||||||
|
## Detaljerade ringar och lokala stenar
|
||||||
|
|
||||||
|
Saturnus `rings` innehåller samtliga inställningar. En deterministisk radiell profil används gemensamt av ringarnas färg, optiska täthet, ringskuggorna och stenarnas fördelning. Profilen filtreras med mipmaps på avstånd för att undvika flimrande tunna linjer. Den nedskalas om grafikkortets maximala texturbredd är lägre än `profileResolution`.
|
||||||
|
|
||||||
|
| Fält i `rings` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `seed` | Seed för ringstruktur och stenarnas stabila positioner |
|
||||||
|
| `innerRadiusMeters`, `outerRadiusMeters`, `thicknessMeters` | Ringarnas radier från planetcentrum och stenlagrets tjocklek |
|
||||||
|
| `color`, `opacity` | Grundfärg och övergripande täthet |
|
||||||
|
| `gapInnerRadiusMeters`, `gapOuterRadiusMeters` | Den stora Cassinidelningen |
|
||||||
|
| `bands` | Lista med `innerRadiusMeters`, `outerRadiusMeters`, `density`, `color`; Saturnus har C-, B- och A-band |
|
||||||
|
| `gaps` | Fler intervall med radier och `densityMultiplier`; `0` ger en tom lucka utan stenar |
|
||||||
|
|
||||||
|
`appearance` styr ringmaterialet:
|
||||||
|
|
||||||
|
| Fält | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `profileResolution`, `fineBandCount`, `microBandCount`, `bandContrast` | Profilens upplösning och mängden oregelbundna fina ringband |
|
||||||
|
| `opticalDepth`, `outerColor` | Gemensam optisk täthet och yttre reservfärg när inga explicita band anges |
|
||||||
|
| `sunlightStrength`, `ambientStrength` | Belysning av ringmaterialet |
|
||||||
|
| `forwardScattering`, `backlitStrength` | Spridning och genomlysning när solen ligger bakom ringarna |
|
||||||
|
| `shadowSoftnessMeters` | Mjukhet på planetens skugga och ringskuggor |
|
||||||
|
| `dustOpacity` | Den andel dis som återstår där lokala stenar ersätter det sammanhängande ringmaterialet |
|
||||||
|
|
||||||
|
`rocks` styr närbildens geometri:
|
||||||
|
|
||||||
|
| Fält | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled` | Aktivera lokala stenar; `false` behåller ringmaterialet på alla avstånd |
|
||||||
|
| `cellSizeMeters`, `particlesPerCell`, `densityScale` | Cellstorlek, antal kandidater och täthetsfaktor; tomma luckor förblir tomma |
|
||||||
|
| `streamRadiusMeters`, `maximumInstances` | Lokal räckvidd och hård gräns för antal instanser; avlägsna celler rensas |
|
||||||
|
| `minRadiusMeters`, `maxRadiusMeters`, `sizeExponent` | Storleksintervall; större exponent ger fler små stenar |
|
||||||
|
| `meshDistanceMeters`, `meshLodPixels`, `lodTransitionMeters` | Avstånd, projicerad storlek och övergång mellan ljussatta punktpartiklar och 3D-stenar |
|
||||||
|
| `fadeStartMeters` | Avstånd där stenarna börjar tonas ut mot räckviddens slut |
|
||||||
|
| `shapeIrregularity`, `roughness`, `iceStrength` | Oregelbunden form, materialets strävhet och isreflexer |
|
||||||
|
| `sunlightStrength`, `ambientStrength`, `minColor`, `maxColor` | Ljus och färgvariation mellan stenarna |
|
||||||
|
|
||||||
|
Instanserna förankras nära kameran i dubbelprecision före omvandling till GPU-koordinater, så meterstora stenar behåller precisionen vid astronomiska avstånd. De är visuella partiklar med stabila seedade positioner. Individuella stenbanor, gravitation och kollisioner med stenarna simuleras inte. `maximumInstances` begränsar närbildens belastning; för hög täthet kan fylla budgeten innan hela räckvidden täcks.
|
||||||
|
|
||||||
|
## Prova Saturnus
|
||||||
|
|
||||||
|
Tryck **F4** från startvyn vid Jorden för en mjuk flygning till närmaste ringsystem. Kameran lämnar först den närliggande stenplaneten, anländer vid ringarna och sänker hastigheten. Tangenten som är mappad till `saturn` (standard **6**) ger sedan en översikt ovanför Saturnus ringplan. **F1** frigör musen; startflaggan `--free-mouse` gör det direkt vid start.
|
||||||
|
|
||||||
|
`rings.preview` gör båda vyerna konfigurerbara:
|
||||||
|
|
||||||
|
| Fält | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `radiusFraction`, `azimuthDegrees`, `heightMeters` | Närbildens radiella läge mellan ringarnas kanter, vinkel i ringplanet och höjd över planet |
|
||||||
|
| `yawOffsetDegrees`, `pitchDegrees`, `rollDegrees` | Riktning från planetcentrum, blickens lutning och kamerans roll |
|
||||||
|
| `fieldOfViewDegrees`, `flightSeconds`, `speedMetersPerSecond` | Närbildens synfält, flygtid och efterföljande hastighet |
|
||||||
|
| `overviewDistanceInOuterRadii`, `overviewElevationDegrees`, `overviewSunAngleDegrees` | Översiktens avstånd, höjdvinkel och riktning i förhållande till solen |
|
||||||
|
|
||||||
|
Ringarna är bara tiotals meter tjocka men tiotals miljoner meter breda. En vy nära stenarna visar därför nästan vågräta ringar, medan översikten visar hela bandstrukturen. Ändra `heightMeters`, kameravinklar eller höjden med Space/Ctrl för andra perspektiv.
|
||||||
|
|
||||||
|
## Asteroiddetaljer nära kameran
|
||||||
|
|
||||||
|
`rings.rocks.asteroid` styr närbilden. Stenarna använder delade icosfärmeshar med jämnt fördelade trianglar och seedad, individuellt deformerad form. Detailjnivån ökar med projicerad diameter på skärmen. Standardnivåerna innehåller 80, 320, 1 280, 5 120, 20 480 respektive 81 920 trianglar. Det ger tät geometri när en sten fyller bilden, medan bakgrundens partiklar fortsätter använda billigare representationer.
|
||||||
|
|
||||||
|
Formen kombinerar domain-warped gradientnoise i flera oktaver, oregelbundna proportioner, ryggar och ett cellulärt kraterfält med skålformade sänkor och kanter. Stora kratrar påverkar geometrin; mindre kratrar och korn påverkar ytans normaler. Detaljerna följer stenens lokala koordinater och rotation, så de sitter kvar när kameran flyttas. Samma heltalshash används i vertex- och fragmentshadern för stabila kraterpositioner.
|
||||||
|
|
||||||
|
| Fält i `asteroid` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `baseSubdivision`, `maxSubdivision` | Lägsta och högsta icosfärnivå; maximalt `6` |
|
||||||
|
| `lodStartPixels`, `lodPixelMultiplier` | Skärmdiameter där nästa nivå börjar och faktor mellan nivåernas trösklar |
|
||||||
|
| `highDetailDistanceMeters`, `maxHighDetailInstances` | Räckvidd och hård instansbudget för nivåer över `3`; störst på skärmen prioriteras |
|
||||||
|
| `elongation` | Variation i stenarnas proportioner |
|
||||||
|
| `noiseFrequency`, `noiseOctaves`, `noisePersistence` | Grundformens gradientnoise och fördelningen av detalj mellan oktaver |
|
||||||
|
| `ridgeStrength` | Relief från ryggar och bergknallar |
|
||||||
|
| `craterFrequency`, `craterStrength`, `craterDensity`, `craterRimHeight` | Kratrarnas storlek, djup, förekomst och kanthöjd |
|
||||||
|
| `surfaceDetailStrength`, `surfaceDetailFrequency`, `normalStrength` | Finare ytstruktur och hur kraftigt den påverkar skuggningen |
|
||||||
|
|
||||||
|
Den befintliga `rocks.shapeIrregularity` styr fortfarande grundformens amplitud. LOD byts med hysteres för att undvika upprepade byten vid en tröskel. Högsta geometrin kan skapas första gången en sten blir tillräckligt stor på skärmen. Alla instanser återanvänder samma basmeshar; noisen och kratrarna skiljer dem åt.
|
||||||
|
|
||||||
|
## Solstrålar och linsglöd
|
||||||
|
|
||||||
|
Varje stjärna har ett `glow`-block. Det gäller även proceduriella solar, som får samma standardinställningar. Solen i katalogen visar alla värden explicit.
|
||||||
|
|
||||||
|
| Fält i `glow` | Effekt |
|
||||||
|
| --- | --- |
|
||||||
|
| `enabled`, `intensity` | Aktivering och styrka |
|
||||||
|
| `referenceDistanceAu`, `distanceFalloff` | Avståndsskala i AU och hur snabbt glöden avtar |
|
||||||
|
| `apparentRadiusMultiplier` | Visuell skivstorlek; växlar mjukt till förstoring när kameran är långt från stjärnan, utan att ändra dess fysiska storlek eller ljusberäkningen |
|
||||||
|
| `minimumCorePixels` | Minsta storlek på den lysande kärnan, så solen syns även vid yttre planeter |
|
||||||
|
| `haloRadiusDegrees`, `haloStrength` | Glödens vinkelstorlek och styrka |
|
||||||
|
| `rayCount`, `rayLengthDegrees`, `raySharpness`, `rayStrength` | Antal riktade strålar, längd, skärpa och styrka |
|
||||||
|
| `ghostStrength`, `ghostSpacing`, `chromaticAberration` | Linsreflexer längs axeln genom bildcentrum och deras färgseparation |
|
||||||
|
|
||||||
|
Styrkan beräknas som `intensity × sqrt(luminositySolar) / (1 + (distanceAu / referenceDistanceAu)^distanceFalloff)`. Den avtar kontinuerligt utan att klippas bort vid ett bestämt avstånd. Detta är en visuell kameramodell, där dämpningen kan göras långsammare än fysisk invers kvadrat för att solen fortfarande ska synas vid exempelvis 30 AU.
|
||||||
|
|
||||||
|
Strålar och reflexer beräknas separat för varje sol och läggs till i HDR före bloom och tonmappning. Djupbufferten samplas över solskivan: objekt framför solen dämpar eller släcker glöden. Reflexerna flyttas med solens läge på skärmen och är tydligast när solen ligger vid sidan av bildcentrum.
|
||||||
|
|
||||||
|
## Automatiska grafikrapporter
|
||||||
|
|
||||||
|
Rapporterna använder appens riktiga OpenGL-shaders i ett dolt fönster och avslutas automatiskt utan att fånga musen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run -c Release --project src/Universe.App -- --graphics-report --validate-graphics
|
||||||
|
dotnet run -c Release --project src/Universe.App -- --scene-report
|
||||||
|
```
|
||||||
|
|
||||||
|
`graphics-report/` bredvid den byggda appen får PNG-bilder. Det första kommandot kontrollerar den tätaste asteroidmeshen, giltiga shaderresultat, två solars glöd vid 1 respektive 30 AU samt att framförliggande djup släcker glöden. Det andra renderar den riktiga Saturnusscenen, en närliggande ringsten och Solen från 30 AU genom hela renderingskedjan.
|
||||||
BIN
KnownSolarSystem/Textures/earth-color.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
KnownSolarSystem/Textures/earth-color.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
KnownSolarSystem/Textures/earth-height.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
KnownSolarSystem/Textures/earth-height.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
719
KnownSolarSystem/solar-systems.json
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"systems": [
|
||||||
|
{
|
||||||
|
"id": "sol",
|
||||||
|
"name": "Vårt solsystem",
|
||||||
|
"sector": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"seed": 1,
|
||||||
|
"centerOffsetMeters": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"stars": [
|
||||||
|
{
|
||||||
|
"id": "sun",
|
||||||
|
"name": "Solen",
|
||||||
|
"seed": 100,
|
||||||
|
"positionMeters": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"radiusMeters": 695700000,
|
||||||
|
"luminositySolar": 1.0,
|
||||||
|
"color": [
|
||||||
|
1.0,
|
||||||
|
0.94,
|
||||||
|
0.85
|
||||||
|
],
|
||||||
|
"glow": {
|
||||||
|
"enabled": true,
|
||||||
|
"intensity": 2.4,
|
||||||
|
"referenceDistanceAu": 1,
|
||||||
|
"distanceFalloff": 0.65,
|
||||||
|
"apparentRadiusMultiplier": 4.5,
|
||||||
|
"minimumCorePixels": 5.0,
|
||||||
|
"haloRadiusDegrees": 3.2,
|
||||||
|
"haloStrength": 0.85,
|
||||||
|
"rayCount": 8,
|
||||||
|
"rayLengthDegrees": 12,
|
||||||
|
"raySharpness": 90,
|
||||||
|
"rayStrength": 2.1,
|
||||||
|
"ghostStrength": 0.04,
|
||||||
|
"ghostSpacing": 0.42,
|
||||||
|
"chromaticAberration": 0.08
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"planets": [
|
||||||
|
{
|
||||||
|
"id": "mercury",
|
||||||
|
"name": "Merkurius",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 101,
|
||||||
|
"description": "Minsta planeten. Stenig yta och nästan ingen atmosfär.",
|
||||||
|
"radiusMeters": 2439700,
|
||||||
|
"axialTiltDegrees": 0.034,
|
||||||
|
"rotationDegrees": 15,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 0.3871,
|
||||||
|
"eccentricity": 0.2056,
|
||||||
|
"phaseDegrees": 160,
|
||||||
|
"inclinationDegrees": 7.0,
|
||||||
|
"ascendingNodeDegrees": 48.3,
|
||||||
|
"periapsisDegrees": 29.1
|
||||||
|
},
|
||||||
|
"surfaceType": "rocky",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.38,
|
||||||
|
0.36,
|
||||||
|
0.33
|
||||||
|
],
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 3000,
|
||||||
|
"proceduralNoiseFrequency": 2.0
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 0,
|
||||||
|
"density": 0,
|
||||||
|
"color": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"composition": "Mycket tunn exosfär"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "venus",
|
||||||
|
"name": "Venus",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 102,
|
||||||
|
"description": "Tät koldioxidatmosfär och kraftig växthuseffekt. Molntäckt yta.",
|
||||||
|
"radiusMeters": 6051800,
|
||||||
|
"axialTiltDegrees": 177.4,
|
||||||
|
"rotationDegrees": 20,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 0.7233,
|
||||||
|
"eccentricity": 0.0068,
|
||||||
|
"phaseDegrees": 75,
|
||||||
|
"inclinationDegrees": 3.4,
|
||||||
|
"ascendingNodeDegrees": 76.7,
|
||||||
|
"periapsisDegrees": 54.9
|
||||||
|
},
|
||||||
|
"surfaceType": "cloudCovered",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.72,
|
||||||
|
0.56,
|
||||||
|
0.31
|
||||||
|
],
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 0,
|
||||||
|
"proceduralNoiseFrequency": 1
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 250000,
|
||||||
|
"density": 8,
|
||||||
|
"color": [
|
||||||
|
0.65,
|
||||||
|
0.47,
|
||||||
|
0.22
|
||||||
|
],
|
||||||
|
"composition": "Koldioxid, kväve och svavelsyramoln"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "earth",
|
||||||
|
"name": "Jorden",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 103,
|
||||||
|
"description": "Vår hemplanet. Hav, kontinenter och en kväve- och syrerik atmosfär.",
|
||||||
|
"radiusMeters": 6371000,
|
||||||
|
"axialTiltDegrees": 23.44,
|
||||||
|
"rotationDegrees": 135,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 1.0,
|
||||||
|
"eccentricity": 0.0167,
|
||||||
|
"phaseDegrees": 0,
|
||||||
|
"inclinationDegrees": 0,
|
||||||
|
"ascendingNodeDegrees": 0,
|
||||||
|
"periapsisDegrees": 0
|
||||||
|
},
|
||||||
|
"surfaceType": "terrestrial",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.25,
|
||||||
|
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]
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"heightmapMinMeters": 0,
|
||||||
|
"heightmapMaxMeters": 10848,
|
||||||
|
"heightmapGamma": 1.0,
|
||||||
|
"proceduralNoiseAmplitudeMeters": 120,
|
||||||
|
"proceduralNoiseFrequency": 24,
|
||||||
|
"noiseMask": "heightmapLand",
|
||||||
|
"landThreshold": 0.02,
|
||||||
|
"landBlend": 0.04
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 220000,
|
||||||
|
"density": 1.0,
|
||||||
|
"color": [
|
||||||
|
0.13,
|
||||||
|
0.32,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"composition": "Kväve och syre"
|
||||||
|
},
|
||||||
|
"moons": [
|
||||||
|
{
|
||||||
|
"id": "moon",
|
||||||
|
"name": "Månen",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 109,
|
||||||
|
"description": "Jordens naturliga satellit.",
|
||||||
|
"radiusMeters": 1737400,
|
||||||
|
"axialTiltDegrees": 6.7,
|
||||||
|
"rotationDegrees": 0,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 0.00256955,
|
||||||
|
"eccentricity": 0.0549,
|
||||||
|
"phaseDegrees": 60,
|
||||||
|
"inclinationDegrees": 5.1,
|
||||||
|
"ascendingNodeDegrees": 0,
|
||||||
|
"periapsisDegrees": 0
|
||||||
|
},
|
||||||
|
"surfaceType": "rocky",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.37,
|
||||||
|
0.36,
|
||||||
|
0.34
|
||||||
|
],
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 2000,
|
||||||
|
"proceduralNoiseFrequency": 2.0
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 0,
|
||||||
|
"density": 0,
|
||||||
|
"color": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"composition": "Mycket tunn exosfär"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mars",
|
||||||
|
"name": "Mars",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 104,
|
||||||
|
"description": "Den röda planeten. Tunn atmosfär och spår av tidigare vatten.",
|
||||||
|
"radiusMeters": 3389500,
|
||||||
|
"axialTiltDegrees": 25.2,
|
||||||
|
"rotationDegrees": 20,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 1.5237,
|
||||||
|
"eccentricity": 0.0934,
|
||||||
|
"phaseDegrees": 140,
|
||||||
|
"inclinationDegrees": 1.8,
|
||||||
|
"ascendingNodeDegrees": 49.6,
|
||||||
|
"periapsisDegrees": 286.5
|
||||||
|
},
|
||||||
|
"surfaceType": "rocky",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.56,
|
||||||
|
0.24,
|
||||||
|
0.12
|
||||||
|
],
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 12000,
|
||||||
|
"proceduralNoiseFrequency": 1.4
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 280000,
|
||||||
|
"density": 0.08,
|
||||||
|
"color": [
|
||||||
|
0.7,
|
||||||
|
0.55,
|
||||||
|
0.36
|
||||||
|
],
|
||||||
|
"composition": "Tunn koldioxidatmosfär"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jupiter",
|
||||||
|
"name": "Jupiter",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 105,
|
||||||
|
"description": "Största planeten. En bandad gasjätte med starkt magnetfält.",
|
||||||
|
"radiusMeters": 69911000,
|
||||||
|
"axialTiltDegrees": 3.1,
|
||||||
|
"rotationDegrees": 20,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 5.2034,
|
||||||
|
"eccentricity": 0.0489,
|
||||||
|
"phaseDegrees": 220,
|
||||||
|
"inclinationDegrees": 1.3,
|
||||||
|
"ascendingNodeDegrees": 100.5,
|
||||||
|
"periapsisDegrees": 273.9
|
||||||
|
},
|
||||||
|
"surfaceType": "gasGiant",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.83,
|
||||||
|
0.77,
|
||||||
|
0.66
|
||||||
|
],
|
||||||
|
"cloudBandFrequency": 32,
|
||||||
|
"stormStrength": 0.85,
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 0,
|
||||||
|
"proceduralNoiseFrequency": 1
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 300000,
|
||||||
|
"density": 1.5,
|
||||||
|
"color": [
|
||||||
|
0.6,
|
||||||
|
0.46,
|
||||||
|
0.32
|
||||||
|
],
|
||||||
|
"composition": "Väte och helium"
|
||||||
|
},
|
||||||
|
"gas": {
|
||||||
|
"polarColor": [
|
||||||
|
0.41,
|
||||||
|
0.39,
|
||||||
|
0.35
|
||||||
|
],
|
||||||
|
"bandContrast": 0.9,
|
||||||
|
"polarRadiusScale": 0.935,
|
||||||
|
"bandColor": [
|
||||||
|
0.48,
|
||||||
|
0.25,
|
||||||
|
0.13
|
||||||
|
],
|
||||||
|
"hazeColor": [
|
||||||
|
0.7,
|
||||||
|
0.64,
|
||||||
|
0.53
|
||||||
|
],
|
||||||
|
"fineBandContrast": 0.16,
|
||||||
|
"cloudTurbulence": 0.55
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "saturn",
|
||||||
|
"name": "Saturnus",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 106,
|
||||||
|
"description": "Gasjätte med ett stort och tydligt ringsystem.",
|
||||||
|
"radiusMeters": 58232000,
|
||||||
|
"axialTiltDegrees": 26.7,
|
||||||
|
"rotationDegrees": 0,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 9.5371,
|
||||||
|
"eccentricity": 0.052,
|
||||||
|
"phaseDegrees": 290,
|
||||||
|
"inclinationDegrees": 2.5,
|
||||||
|
"ascendingNodeDegrees": 113.7,
|
||||||
|
"periapsisDegrees": 339.4
|
||||||
|
},
|
||||||
|
"surfaceType": "gasGiant",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.76,
|
||||||
|
0.71,
|
||||||
|
0.57
|
||||||
|
],
|
||||||
|
"cloudBandFrequency": 36,
|
||||||
|
"stormStrength": 0,
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 0,
|
||||||
|
"proceduralNoiseFrequency": 1
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 300000,
|
||||||
|
"density": 1.1,
|
||||||
|
"color": [
|
||||||
|
0.58,
|
||||||
|
0.5,
|
||||||
|
0.32
|
||||||
|
],
|
||||||
|
"composition": "Väte och helium"
|
||||||
|
},
|
||||||
|
"rings": {
|
||||||
|
"seed": 10621,
|
||||||
|
"innerRadiusMeters": 74658000,
|
||||||
|
"outerRadiusMeters": 136780000,
|
||||||
|
"color": [
|
||||||
|
0.57,
|
||||||
|
0.55,
|
||||||
|
0.49
|
||||||
|
],
|
||||||
|
"opacity": 0.88,
|
||||||
|
"thicknessMeters": 40,
|
||||||
|
"gapInnerRadiusMeters": 117507000,
|
||||||
|
"gapOuterRadiusMeters": 122340000,
|
||||||
|
"appearance": {
|
||||||
|
"profileResolution": 32768,
|
||||||
|
"opticalDepth": 0.9,
|
||||||
|
"fineBandCount": 1800,
|
||||||
|
"microBandCount": 14000,
|
||||||
|
"bandContrast": 0.85,
|
||||||
|
"sunlightStrength": 4.8,
|
||||||
|
"ambientStrength": 0.025,
|
||||||
|
"forwardScattering": 0.65,
|
||||||
|
"backlitStrength": 0.8,
|
||||||
|
"shadowSoftnessMeters": 180000,
|
||||||
|
"dustOpacity": 0.055,
|
||||||
|
"outerColor": [
|
||||||
|
0.74,
|
||||||
|
0.72,
|
||||||
|
0.65
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bands": [
|
||||||
|
{
|
||||||
|
"innerRadiusMeters": 74658000,
|
||||||
|
"outerRadiusMeters": 91975000,
|
||||||
|
"density": 0.23,
|
||||||
|
"color": [
|
||||||
|
0.39,
|
||||||
|
0.38,
|
||||||
|
0.34
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innerRadiusMeters": 91975000,
|
||||||
|
"outerRadiusMeters": 117507000,
|
||||||
|
"density": 1.8,
|
||||||
|
"color": [
|
||||||
|
0.76,
|
||||||
|
0.73,
|
||||||
|
0.64
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innerRadiusMeters": 122340000,
|
||||||
|
"outerRadiusMeters": 136780000,
|
||||||
|
"density": 0.75,
|
||||||
|
"color": [
|
||||||
|
0.64,
|
||||||
|
0.62,
|
||||||
|
0.55
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
{
|
||||||
|
"innerRadiusMeters": 133247500,
|
||||||
|
"outerRadiusMeters": 133572500,
|
||||||
|
"densityMultiplier": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"innerRadiusMeters": 136466000,
|
||||||
|
"outerRadiusMeters": 136508000,
|
||||||
|
"densityMultiplier": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rocks": {
|
||||||
|
"enabled": true,
|
||||||
|
"cellSizeMeters": 256,
|
||||||
|
"particlesPerCell": 384,
|
||||||
|
"streamRadiusMeters": 3500,
|
||||||
|
"maximumInstances": 100000,
|
||||||
|
"minRadiusMeters": 0.06,
|
||||||
|
"maxRadiusMeters": 1.8,
|
||||||
|
"sizeExponent": 2.8,
|
||||||
|
"densityScale": 1,
|
||||||
|
"meshDistanceMeters": 600,
|
||||||
|
"meshLodPixels": 3.5,
|
||||||
|
"lodTransitionMeters": 180,
|
||||||
|
"fadeStartMeters": 2600,
|
||||||
|
"shapeIrregularity": 0.2,
|
||||||
|
"roughness": 0.7,
|
||||||
|
"iceStrength": 0.5,
|
||||||
|
"sunlightStrength": 5.5,
|
||||||
|
"ambientStrength": 0.035,
|
||||||
|
"minColor": [
|
||||||
|
0.28,
|
||||||
|
0.28,
|
||||||
|
0.26
|
||||||
|
],
|
||||||
|
"maxColor": [
|
||||||
|
0.85,
|
||||||
|
0.84,
|
||||||
|
0.77
|
||||||
|
],
|
||||||
|
"asteroid": {
|
||||||
|
"baseSubdivision": 1,
|
||||||
|
"maxSubdivision": 6,
|
||||||
|
"lodStartPixels": 14,
|
||||||
|
"lodPixelMultiplier": 2.3,
|
||||||
|
"highDetailDistanceMeters": 80,
|
||||||
|
"maxHighDetailInstances": 12,
|
||||||
|
"elongation": 0.55,
|
||||||
|
"noiseFrequency": 1.1,
|
||||||
|
"noiseOctaves": 3,
|
||||||
|
"noisePersistence": 0.4,
|
||||||
|
"ridgeStrength": 0.012,
|
||||||
|
"craterFrequency": 10,
|
||||||
|
"craterStrength": 0.28,
|
||||||
|
"craterDensity": 0.72,
|
||||||
|
"craterRimHeight": 0.06,
|
||||||
|
"surfaceDetailStrength": 0.0002,
|
||||||
|
"surfaceDetailFrequency": 150,
|
||||||
|
"normalStrength": 0.8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"radiusFraction": 0.88,
|
||||||
|
"azimuthDegrees": -80,
|
||||||
|
"heightMeters": 120,
|
||||||
|
"yawOffsetDegrees": -18,
|
||||||
|
"pitchDegrees": -12,
|
||||||
|
"fieldOfViewDegrees": 100,
|
||||||
|
"flightSeconds": 8,
|
||||||
|
"speedMetersPerSecond": 40,
|
||||||
|
"rollDegrees": -12,
|
||||||
|
"overviewDistanceInOuterRadii": 1.9,
|
||||||
|
"overviewElevationDegrees": 24,
|
||||||
|
"overviewSunAngleDegrees": 55
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gas": {
|
||||||
|
"outerRadiusScale": 1.055,
|
||||||
|
"polarRadiusScale": 0.902,
|
||||||
|
"density": 420,
|
||||||
|
"scaleHeight": 0.01,
|
||||||
|
"viewSteps": 96,
|
||||||
|
"lightSteps": 7,
|
||||||
|
"sampleDistribution": 3,
|
||||||
|
"stopTransmittance": 0.003,
|
||||||
|
"scatteringAlbedo": 0.94,
|
||||||
|
"lightExtinction": 0.35,
|
||||||
|
"sunlightStrength": 7,
|
||||||
|
"ambientStrength": 0.022,
|
||||||
|
"anisotropy": 0.18,
|
||||||
|
"bandContrast": 0.45,
|
||||||
|
"fineBandFrequency": 290,
|
||||||
|
"fineBandContrast": 0.13,
|
||||||
|
"cloudTurbulence": 0.11,
|
||||||
|
"detailStrength": 0.018,
|
||||||
|
"noiseScale": 72,
|
||||||
|
"windSpeed": 0.012,
|
||||||
|
"bandColor": [
|
||||||
|
0.5,
|
||||||
|
0.46,
|
||||||
|
0.34
|
||||||
|
],
|
||||||
|
"polarColor": [
|
||||||
|
0.31,
|
||||||
|
0.4,
|
||||||
|
0.46
|
||||||
|
],
|
||||||
|
"hazeColor": [
|
||||||
|
0.74,
|
||||||
|
0.76,
|
||||||
|
0.69
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uranus",
|
||||||
|
"name": "Uranus",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 107,
|
||||||
|
"description": "Isgigant med cirka 98 graders axellutning, liggande på sidan.",
|
||||||
|
"radiusMeters": 25362000,
|
||||||
|
"axialTiltDegrees": 97.8,
|
||||||
|
"rotationDegrees": 0,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 19.1913,
|
||||||
|
"eccentricity": 0.0472,
|
||||||
|
"phaseDegrees": 45,
|
||||||
|
"inclinationDegrees": 0.8,
|
||||||
|
"ascendingNodeDegrees": 74.0,
|
||||||
|
"periapsisDegrees": 97.0
|
||||||
|
},
|
||||||
|
"surfaceType": "iceGiant",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.25,
|
||||||
|
0.68,
|
||||||
|
0.73
|
||||||
|
],
|
||||||
|
"cloudBandFrequency": 16,
|
||||||
|
"stormStrength": 0,
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 0,
|
||||||
|
"proceduralNoiseFrequency": 1
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 180000,
|
||||||
|
"density": 1.3,
|
||||||
|
"color": [
|
||||||
|
0.15,
|
||||||
|
0.62,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"composition": "Väte, helium och metan"
|
||||||
|
},
|
||||||
|
"gas": {
|
||||||
|
"polarColor": [
|
||||||
|
0.32,
|
||||||
|
0.58,
|
||||||
|
0.63
|
||||||
|
],
|
||||||
|
"bandContrast": 0.12,
|
||||||
|
"polarRadiusScale": 0.977,
|
||||||
|
"bandColor": [
|
||||||
|
0.36,
|
||||||
|
0.67,
|
||||||
|
0.7
|
||||||
|
],
|
||||||
|
"hazeColor": [
|
||||||
|
0.55,
|
||||||
|
0.79,
|
||||||
|
0.8
|
||||||
|
],
|
||||||
|
"fineBandContrast": 0.025,
|
||||||
|
"cloudTurbulence": 0.04
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "neptune",
|
||||||
|
"name": "Neptunus",
|
||||||
|
"hostStarId": "sun",
|
||||||
|
"seed": 108,
|
||||||
|
"description": "Blå isgigant med kraftiga vindar i den yttre atmosfären.",
|
||||||
|
"radiusMeters": 24622000,
|
||||||
|
"axialTiltDegrees": 28.3,
|
||||||
|
"rotationDegrees": 0,
|
||||||
|
"orbit": {
|
||||||
|
"semiMajorAxisAu": 30.069,
|
||||||
|
"eccentricity": 0.0086,
|
||||||
|
"phaseDegrees": 105,
|
||||||
|
"inclinationDegrees": 1.8,
|
||||||
|
"ascendingNodeDegrees": 131.8,
|
||||||
|
"periapsisDegrees": 273.2
|
||||||
|
},
|
||||||
|
"surfaceType": "iceGiant",
|
||||||
|
"surfaceColor": [
|
||||||
|
0.075,
|
||||||
|
0.21,
|
||||||
|
0.62
|
||||||
|
],
|
||||||
|
"cloudBandFrequency": 22,
|
||||||
|
"stormStrength": 0.45,
|
||||||
|
"terrain": {
|
||||||
|
"proceduralNoiseAmplitudeMeters": 0,
|
||||||
|
"proceduralNoiseFrequency": 1
|
||||||
|
},
|
||||||
|
"atmosphere": {
|
||||||
|
"heightMeters": 180000,
|
||||||
|
"density": 1.4,
|
||||||
|
"color": [
|
||||||
|
0.12,
|
||||||
|
0.28,
|
||||||
|
0.9
|
||||||
|
],
|
||||||
|
"composition": "Väte, helium och metan"
|
||||||
|
},
|
||||||
|
"gas": {
|
||||||
|
"polarColor": [
|
||||||
|
0.13,
|
||||||
|
0.26,
|
||||||
|
0.47
|
||||||
|
],
|
||||||
|
"bandContrast": 0.35,
|
||||||
|
"polarRadiusScale": 0.983,
|
||||||
|
"bandColor": [
|
||||||
|
0.08,
|
||||||
|
0.18,
|
||||||
|
0.46
|
||||||
|
],
|
||||||
|
"hazeColor": [
|
||||||
|
0.24,
|
||||||
|
0.42,
|
||||||
|
0.68
|
||||||
|
],
|
||||||
|
"fineBandContrast": 0.05,
|
||||||
|
"cloudTurbulence": 0.18
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Universe
|
||||||
|
|
||||||
|
En proceduriell 3D-universumsgenerator för Windows med C#/.NET 10, Silk.NET och OpenGL 3.3.
|
||||||
|
|
||||||
|
## Starta
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --configuration Release --project src/Universe.App/Universe.App.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Appen börjar framför **Jorden (`earth`)** i vårt solsystem i cell `(0,0,0)`. Startmål och vy väljs i [config.json](config.json).
|
||||||
|
|
||||||
|
Solen och de åtta planeterna definieras i [KnownSolarSystem/solar-systems.json](KnownSolarSystem/solar-systems.json). Där finns även Jordens måne, Saturnus ringar, planeternas axellutning, atmosfärer och material. Jordens färg- och höjdkartor ligger i `KnownSolarSystem/Textures/`. Se [katalogens dokumentation](KnownSolarSystem/README.md) för fler solsystem och för kombinationen heightmap + noise.
|
||||||
|
|
||||||
|
[Content/README.md](Content/README.md) beskriver markmaterialen och den regionala höjdrutan för Alperna. Materialkatalogen och varje planets `materials`-inställningar är redigerbara i JSON.
|
||||||
|
|
||||||
|
| Kontroll | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| 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 |
|
||||||
|
| Space / Ctrl | Upp / ner relativt kameran |
|
||||||
|
| Mushjul | Logaritmisk hastighet, 0,1 m/s till 1 000 000 c |
|
||||||
|
| Q | Mjuk flygning till närmaste planet |
|
||||||
|
| 1–9 | Flyg till planeten på motsvarande plats i `startup.planetHotkeys` i `config.json` |
|
||||||
|
| F2 | Mjuk flygning till en bergsvy nära ytan; hastigheten sänks till 1 200 m/s |
|
||||||
|
| F3 | Närbild av närmaste stjärna |
|
||||||
|
| F4 | Mjuk flygning till närmaste ringsystem; lokala isstenar och låg flyghastighet |
|
||||||
|
| F1 | Släpp / fånga muspekaren |
|
||||||
|
| 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 |
|
||||||
|
| F7 | Flyg till en vy ovanför närmaste molnlager; hastigheten sänks till 100 m/s |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
**Benchmark Earth** kör en fast 72-sekunders kameraresa vid Jorden: rymdvy, inflygning och separat mjuk bromsning sista 10 km mot Alpernas Copernicus-terrängdata, följt av en långsam promenadslinga med kameran 1,8 m över marken. **Benchmark Unknown** kör samma resa på den största tillgängliga proceduriella stenplaneten bland närliggande system som är större än Jorden; dess högre terrängamplitud ger kraftigare proceduriell relief. Blicken sveper runt under promenaden. När testet är klart återgår kameran till sin ursprungliga vy mot Jorden. Panelen uppe till höger visar målplanet och FPS över tid, medelvärde, lägsta bildfrekvens och 1 %-låg. Tryck P eller Esc för att avbryta och öppna menyn. Benchmarken återställer Debug-läget så debug-prober inte påverkar mätningen.
|
||||||
|
|
||||||
|
Välj **Debug** i P-menyn för en panel till vänster med asynkron GPU-tid för separata renderpass och varje ritad planet, CPU-tid för renderingsanropet, FPS och terrängens LOD/chunkstatus. En gång var åttonde bildruta gör debugläget dessutom en osynlig extrarendering av den dyraste planeten med en effekt avstängd. Panelen visar tidsvinsten jämfört med normalrenderingen för material, molnskuggor, bergsskuggor, planet-/ringskuggor och ytdetalj-noise. Varje effekt mäts separat, och skillnaden medelvärdesbildas över de senaste matchande mätningarna. Proberna är asynkrona och påverkar FPS något när debugläget är på; GPU FRAME visar den vanliga bildrenderingen utan probe-drawen. Skuggorna ingår också i planetens ordinarie tid eftersom de räknas i samma terrängshader.
|
||||||
|
|
||||||
|
## Värld och grafik
|
||||||
|
|
||||||
|
- Kända system från JSON ersätter proceduriell generering i sina tilldelade celler. Övriga 100 AU-celler bestäms av global seed och `SectorCoordinate(long X, long Y, long Z)`. Positionerna behåller dubbelprecision och konverteras till kamerarelativa GPU-koordinater.
|
||||||
|
- Systemens centrum förskjuts med 3D-noise. Stjärnor, lutade elliptiska planetbanor och månar får olika seedade riktningar, faser och avstånd. Alla himlakroppar hålls inom sina celler.
|
||||||
|
- Berg består av domain-warped ridged multifractal-noise. Samma höjdfält används för kollisionsytan, CPU-meshar och GPU-skuggning.
|
||||||
|
- Terrängen är en adaptiv cube-sphere med quadtree-chunks. Rutor utanför synfältet och bakom horisonten sorteras bort med konservativa gränser för berg, dalar och skarvar. Närmaste terräng förfinas först, med högre detalj nära kameran och grövre geometri på avstånd. Föräldrachunken visas tills synliga barn är klara. Chunks förankras i dubbelprecision så närflygning behåller precisionen.
|
||||||
|
- Material varierar med höjd, lutning, mineral-noise och geologiska lager. Terrängnormaler beräknas från höjdfältet vid meshgenerering, kompletteras med finare normaldetaljer och används tillsammans med höjdfältet för bergsskuggor. Alla systemets stjärnor bidrar med ljus och planetförmörkelser.
|
||||||
|
- Atmosfären integrerar Rayleigh- och Mie-spridning längs både kamera- och solljusstrålar. Djupbufferten begränsar integrationen framför marken. Det ger blå himmel, färgade horisonter, dis mellan berg och en atmosfärsrand från rymden.
|
||||||
|
- Stjärnorna har långsamt animerad plasma, filament, aktiva områden, granulation och kantförmörkning. Korona, HDR-bloom, ACES-tonmappning, kantutjämning och dithering behandlas i separata pass.
|
||||||
|
- Bakgrunden består av ett seedat stjärnfält och stjärnorna i granncellerna. Atmosfär och dagsljus dämpar stjärnfältet nära marken.
|
||||||
|
- Gas- och isjättar renderas som ljussatta, tillplattade gasvolymer med band, polarfärger och dimma vid inflygning. De har ingen terrängmesh eller fast kollisionsyta.
|
||||||
|
- Ringarnas optiska profil innehåller oregelbundna band och separata luckor. Samma täthet styr skuggor och lokal generering av isstenar. Nära ringarna ersätts ringmaterialet gradvis av instanser med detaljbyte mellan punktpartiklar och oregelbundna 3D-stenar. Inställningar finns i planetens `gas` och `rings` i JSON; se katalogens dokumentation.
|
||||||
|
- Ringstenar får upp till 81 920 trianglar på riktigt nära håll, med gradientnoise, individuella proportioner, kratrar och finare ytstruktur. `rings.rocks.asteroid` styr geometrins LOD och detaljbudget.
|
||||||
|
- Varje sol har avståndsdämpade HDR-strålar, halo och färgade linsreflexer. Glöden förblir synlig långt ut i systemet och kontrolleras mot framförliggande objekt. Solens `glow`-block styr effekten.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Projekt
|
||||||
|
|
||||||
|
- `src/Universe.Core`: koordinater, systemgenerering, noise, terräng, streaming och svept kollision.
|
||||||
|
- `src/Universe.App`: fönster, kamera och OpenGL. Shaderkoden finns under `Graphics/Shaders/` och bäddas in i bygget.
|
||||||
|
- `tests/Universe.Tests`: koordinater, determinism, cellgränser, terrängkanter, normaler och FTL-kollision.
|
||||||
|
|
||||||
|
## Bygg och verifiera
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build Universe.sln --configuration Release -m:1
|
||||||
|
dotnet test tests/Universe.Tests/Universe.Tests.csproj --configuration Release
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontrollera även den faktiska GPU-höjdfunktionen mot CPU/kollision med:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --configuration Release --project src/Universe.App/Universe.App.csproj -- --validate-graphics
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontrollen läser ett separat numeriskt GPU-testfält och stoppar appen om höjdfälten skiljer sig för mycket. Även Jordens heightmap, axellutning och extra noise jämförs med CPU/kollision. Den vanliga renderingens färgbuffert använder 16-bitars flyttal; testfältet använder 32-bitars flyttal.
|
||||||
|
|
||||||
|
En marknära terrängvy med ruttider och diagnostiska, synkroniserade mätningar per renderingspass kan köras med `dotnet run -c Release --project src/Universe.App -- --terrain-perf-report`. Resultaten varierar med vy, upplösning, laddade rutor och maskin; synkroniseringen i rapporten påverkar också tiderna.
|
||||||
|
|
||||||
|
Atmosfärens integrationsmodell bygger på principerna i [GPU Gems 2, Accurate Atmospheric Scattering](https://developer.nvidia.com/gpugems/gpugems2/part-ii-shading-lighting-and-shadows/chapter-16-accurate-atmospheric-scattering).
|
||||||
|
|
||||||
|
## Skärmbilder
|
||||||
|
|
||||||
|
Bilder direkt från Release-appens OpenGL-rendering:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Även standardvyn för tangent 2 finns sparad i `artifacts/surface-view.jpg`.
|
||||||
|
|
||||||
|
Närbild och avståndsdämpad solglöd, sparade från den riktiga renderingskedjan:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Automatiska GPU-kontroller och nya bilder kan genereras med `--graphics-report` respektive `--scene-report`. Se katalogens dokumentation för kommandon och JSON-inställningar.
|
||||||
71
Universe.sln
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Universe.Core", "src\Universe.Core\Universe.Core.csproj", "{7A605552-EE41-4CC5-AC9D-FC431EB87D73}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Universe.App", "src\Universe.App\Universe.App.csproj", "{E39061E5-0876-42E5-AA16-A1A898D2F0C1}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Universe.Tests", "tests\Universe.Tests\Universe.Tests.csproj", "{CC90DB70-AAEF-4AED-8B34-B22088B47B51}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{7A605552-EE41-4CC5-AC9D-FC431EB87D73} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{E39061E5-0876-42E5-AA16-A1A898D2F0C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{CC90DB70-AAEF-4AED-8B34-B22088B47B51} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
23
config.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"universeSeed": "0x554E495645525345",
|
||||||
|
"cellSizeAu": 100,
|
||||||
|
"knownSolarSystemsFile": "KnownSolarSystem/solar-systems.json",
|
||||||
|
"startup": {
|
||||||
|
"systemId": "sol",
|
||||||
|
"planetId": "earth",
|
||||||
|
"planetHotkeys": [
|
||||||
|
"mercury",
|
||||||
|
"venus",
|
||||||
|
"earth",
|
||||||
|
"mars",
|
||||||
|
"jupiter",
|
||||||
|
"saturn",
|
||||||
|
"uranus",
|
||||||
|
"neptune"
|
||||||
|
],
|
||||||
|
"distanceInRadii": 3.0,
|
||||||
|
"longitudeDegrees": 20,
|
||||||
|
"latitudeDegrees": 20,
|
||||||
|
"speedMetersPerSecond": 200000
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/Universe.App/Graphics/AdaptivePlanetMesh.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Builds tiles on worker threads. A parent remains visible until all four children are ready.</summary>
|
||||||
|
internal sealed class AdaptivePlanetMesh : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly SemaphoreSlim Workers = new(System.Math.Clamp(Environment.ProcessorCount - 2, 1, 4));
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly PlanetData _planet;
|
||||||
|
private readonly Dictionary<TerrainPatchKey, Tile> _tiles = [];
|
||||||
|
private readonly CancellationTokenSource _cancel = new();
|
||||||
|
private int _frame;
|
||||||
|
private int _uploads;
|
||||||
|
private int _pending;
|
||||||
|
private DVector3 _forward;
|
||||||
|
private DVector3 _right;
|
||||||
|
private DVector3 _up;
|
||||||
|
private double _aspect;
|
||||||
|
private double _tanHalfFov;
|
||||||
|
private DVector3 _cameraDirection;
|
||||||
|
private double _horizonAngle;
|
||||||
|
private double _localSurfaceRadius;
|
||||||
|
private double _innerRadius;
|
||||||
|
private double _nearSurfaceDistance;
|
||||||
|
public int DrawnTiles { get; private set; }
|
||||||
|
public int DeepestLevel { get; private set; }
|
||||||
|
public int PendingTiles => _pending;
|
||||||
|
public int NearestLevel { get; private set; }
|
||||||
|
public double NearestDistance { get; private set; }
|
||||||
|
|
||||||
|
public AdaptivePlanetMesh(GL gl, PlanetData planet)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
_planet = planet;
|
||||||
|
for (var face = 0; face < 6; face++)
|
||||||
|
{
|
||||||
|
var key = new TerrainPatchKey(face, 0, 0, 0);
|
||||||
|
var data = TerrainPatchBuilder.Build(planet, key);
|
||||||
|
_tiles[key] = new() { Data = data, Mesh = new(gl, 9, data.Vertices, data.Indices) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(DVector3 cameraLocal, DVector3 forward, DVector3 right, DVector3 up, int screenHeight, double aspect, double tanHalfFov, Action<DVector3, GpuMesh> draw)
|
||||||
|
{
|
||||||
|
_forward = forward; _right = right; _up = up; _aspect = aspect; _tanHalfFov = tanHalfFov;
|
||||||
|
_cameraDirection = cameraLocal.Normalized();
|
||||||
|
_localSurfaceRadius = TerrainHeight.Sample(_planet, _cameraDirection);
|
||||||
|
var altitude = System.Math.Abs(cameraLocal.Length - _localSurfaceRadius);
|
||||||
|
_nearSurfaceDistance = altitude < 1000 ? altitude + 20 : 0;
|
||||||
|
_innerRadius = TerrainVisibility.InnerRadius(_planet);
|
||||||
|
_horizonAngle = TerrainVisibility.HorizonAngle(cameraLocal, _innerRadius, _planet.MaximumTerrainRadiusMeters);
|
||||||
|
_frame++;
|
||||||
|
_uploads = 0;
|
||||||
|
foreach (var tile in _tiles.Values)
|
||||||
|
{
|
||||||
|
if (tile.Task is not { IsCompleted: true }) continue;
|
||||||
|
tile.Data = tile.Task.GetAwaiter().GetResult();
|
||||||
|
tile.Task = null;
|
||||||
|
_pending--;
|
||||||
|
}
|
||||||
|
DrawnTiles = 0;
|
||||||
|
DeepestLevel = 0;
|
||||||
|
NearestLevel = 0; NearestDistance = double.PositiveInfinity;
|
||||||
|
Span<TerrainPatchKey> roots = stackalloc TerrainPatchKey[6];
|
||||||
|
for (var face = 0; face < roots.Length; face++) roots[face] = new(face, 0, 0, 0);
|
||||||
|
SortNearest(roots, cameraLocal);
|
||||||
|
foreach (var root in roots) Visit(root, cameraLocal, screenHeight, draw);
|
||||||
|
if (_frame % 120 != 0) return;
|
||||||
|
foreach (var pair in _tiles.Where(pair => pair.Key.Level > 0 && pair.Value.Task is null && _frame - pair.Value.LastFrame > 360).ToArray())
|
||||||
|
{
|
||||||
|
pair.Value.Mesh?.Dispose();
|
||||||
|
_tiles.Remove(pair.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Visit(TerrainPatchKey key, DVector3 camera, int screenHeight, Action<DVector3, GpuMesh> draw)
|
||||||
|
{
|
||||||
|
if (!Needed(key, camera)) return;
|
||||||
|
var radius = _planet.RadiusMeters;
|
||||||
|
var tile = _tiles[key];
|
||||||
|
tile.LastFrame = _frame;
|
||||||
|
var distance = Distance(key, camera);
|
||||||
|
var spacingPixels = radius * key.Width / TerrainPatchBuilder.Segments / System.Math.Max(distance, 1) * screenHeight / (2 * _tanHalfFov);
|
||||||
|
if (spacingPixels > TerrainVisibility.PixelTarget(distance) && key.Level < 19)
|
||||||
|
{
|
||||||
|
Span<TerrainPatchKey> buffer = stackalloc TerrainPatchKey[4];
|
||||||
|
var childCount = 0;
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
var child = key.Child(i);
|
||||||
|
if (Needed(child, camera)) buffer[childCount++] = child;
|
||||||
|
}
|
||||||
|
var children = buffer[..childCount];
|
||||||
|
SortNearest(children, camera);
|
||||||
|
var ready = true;
|
||||||
|
foreach (var child in children) ready &= EnsureReady(child);
|
||||||
|
if (ready)
|
||||||
|
{
|
||||||
|
foreach (var child in children) Visit(child, camera, screenHeight, draw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (GeometryVisible(tile.Data!, camera))
|
||||||
|
{
|
||||||
|
DrawnTiles++;
|
||||||
|
DeepestLevel = System.Math.Max(DeepestLevel, key.Level);
|
||||||
|
if (distance < NearestDistance) { NearestDistance = distance; NearestLevel = key.Level; }
|
||||||
|
draw(tile.Data!.Origin, tile.Mesh!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double Distance(TerrainPatchKey key, DVector3 camera) => TerrainVisibility.DistanceToPatch(key, camera, _localSurfaceRadius);
|
||||||
|
private bool Needed(TerrainPatchKey key, DVector3 camera)
|
||||||
|
=> Visible(key, camera) || (_nearSurfaceDistance > 0 && Distance(key, camera) <= _nearSurfaceDistance);
|
||||||
|
|
||||||
|
private void SortNearest(Span<TerrainPatchKey> keys, DVector3 camera)
|
||||||
|
{
|
||||||
|
for (var i = 1; i < keys.Length; i++)
|
||||||
|
{
|
||||||
|
var key = keys[i]; var distance = Distance(key, camera); var j = i;
|
||||||
|
while (j > 0 && Distance(keys[j - 1], camera) > distance) { keys[j] = keys[j - 1]; j--; }
|
||||||
|
keys[j] = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Visible(TerrainPatchKey key, DVector3 camera)
|
||||||
|
{
|
||||||
|
var radius = _planet.RadiusMeters;
|
||||||
|
var outer = _planet.MaximumTerrainRadiusMeters;
|
||||||
|
// Include the recessed skirt, but keep its uncertainty radial instead of expanding
|
||||||
|
// the tile by the planet's full mountain height in every horizontal direction.
|
||||||
|
var spacing = radius * key.Width / TerrainPatchBuilder.Segments;
|
||||||
|
var skirt = spacing * 2 + System.Math.Min(_planet.TerrainAmplitudeMeters * .012, spacing * 2);
|
||||||
|
var lower = System.Math.Max(0, _innerRadius - skirt);
|
||||||
|
if (TerrainVisibility.OutsideFrustum(key, camera, _forward, _right, _up, _tanHalfFov, _aspect, lower, outer)) return false;
|
||||||
|
return !TerrainVisibility.BehindHorizon(key, _cameraDirection, _horizonAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool GeometryVisible(TerrainPatchData data, DVector3 camera)
|
||||||
|
{
|
||||||
|
var toPatch = data.BoundsCenter - camera;
|
||||||
|
var z = DVector3.Dot(toPatch, _forward);
|
||||||
|
var bound = data.BoundsRadius;
|
||||||
|
var horizontalTan = _tanHalfFov * _aspect;
|
||||||
|
return z >= -bound && System.Math.Abs(DVector3.Dot(toPatch, _up)) <= z * _tanHalfFov + bound * System.Math.Sqrt(1 + _tanHalfFov * _tanHalfFov)
|
||||||
|
&& System.Math.Abs(DVector3.Dot(toPatch, _right)) <= z * horizontalTan + bound * System.Math.Sqrt(1 + horizontalTan * horizontalTan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool EnsureReady(TerrainPatchKey key)
|
||||||
|
{
|
||||||
|
if (!_tiles.TryGetValue(key, out var tile))
|
||||||
|
{
|
||||||
|
if (_pending >= 16) return false;
|
||||||
|
tile = new() { LastFrame = _frame };
|
||||||
|
_tiles.Add(key, tile);
|
||||||
|
_pending++;
|
||||||
|
var token = _cancel.Token;
|
||||||
|
tile.Task = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Workers.WaitAsync(token);
|
||||||
|
try { token.ThrowIfCancellationRequested(); return TerrainPatchBuilder.Build(_planet, key); }
|
||||||
|
finally { Workers.Release(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tile.LastFrame = _frame;
|
||||||
|
if (tile.Mesh is not null) return true;
|
||||||
|
if (tile.Data is null || _uploads >= 6) return false;
|
||||||
|
tile.Mesh = new(_gl, 9, tile.Data.Vertices, tile.Data.Indices);
|
||||||
|
_uploads++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cancel.Cancel();
|
||||||
|
foreach (var tile in _tiles.Values) tile.Mesh?.Dispose();
|
||||||
|
_tiles.Clear();
|
||||||
|
_cancel.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Tile
|
||||||
|
{
|
||||||
|
public TerrainPatchData? Data;
|
||||||
|
public Task<TerrainPatchData>? Task;
|
||||||
|
public GpuMesh? Mesh;
|
||||||
|
public int LastFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/Universe.App/Graphics/BenchmarkOverlay.cs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.App.Runtime;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Compact live/results panel with a decimated frame-rate history graph.</summary>
|
||||||
|
internal sealed class BenchmarkOverlay : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GpuMesh _panel;
|
||||||
|
private readonly GpuMesh _grid;
|
||||||
|
private readonly GpuMesh _curve;
|
||||||
|
private long _lastRevision = -1;
|
||||||
|
private int _lastTextTick = -1;
|
||||||
|
private string _lastPhase = string.Empty;
|
||||||
|
|
||||||
|
public BenchmarkOverlay(GL gl)
|
||||||
|
{
|
||||||
|
_panel = new(gl, 6, [], dynamic: true);
|
||||||
|
_grid = new(gl, 6, [], dynamic: true);
|
||||||
|
_curve = new(gl, 6, [], dynamic: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(int width, int height, BenchmarkRunner benchmark)
|
||||||
|
{
|
||||||
|
var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.2);
|
||||||
|
var x = width - 402 * scale;
|
||||||
|
var y = 14 * scale;
|
||||||
|
const double logicalWidth = 388;
|
||||||
|
const double logicalHeight = 232;
|
||||||
|
var panelWidth = logicalWidth * scale;
|
||||||
|
var panelHeight = logicalHeight * scale;
|
||||||
|
var graphX = x + 17 * scale;
|
||||||
|
var graphY = y + 151 * scale;
|
||||||
|
var graphWidth = (logicalWidth - 34) * scale;
|
||||||
|
var graphHeight = 62 * scale;
|
||||||
|
var tick = (int)(benchmark.ElapsedSeconds * 5);
|
||||||
|
|
||||||
|
if (_lastRevision != benchmark.GraphRevision)
|
||||||
|
{
|
||||||
|
UpdateGraph(width, height, benchmark.GraphSamples, graphX, graphY, graphWidth, graphHeight, scale);
|
||||||
|
_lastRevision = benchmark.GraphRevision;
|
||||||
|
}
|
||||||
|
if (_lastTextTick != tick || _lastPhase != benchmark.Phase)
|
||||||
|
{
|
||||||
|
UpdatePanel(width, height, benchmark, x, y, panelWidth, panelHeight, scale);
|
||||||
|
_lastTextTick = tick;
|
||||||
|
_lastPhase = benchmark.Phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
_panel.Draw(GLEnum.Triangles);
|
||||||
|
_grid.Draw(GLEnum.Lines);
|
||||||
|
_curve.Draw(GLEnum.LineStrip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePanel(int width, int height, BenchmarkRunner benchmark, double x, double y,
|
||||||
|
double panelWidth, double panelHeight, double scale)
|
||||||
|
{
|
||||||
|
var vertices = new List<float>(20_000);
|
||||||
|
Rect(x, y, panelWidth, panelHeight, .018f, .028f, .05f);
|
||||||
|
Rect(x, y, 3 * scale, panelHeight, .13f, .55f, .8f);
|
||||||
|
Text(benchmark.IsRunning ? "BENCHMARK LIVE" : benchmark.Phase == "STOPPED" ? "BENCHMARK STOPPED" : "BENCHMARK COMPLETE",
|
||||||
|
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($"TARGET {benchmark.TargetLabel}", x + 16 * scale, y + 47 * scale,
|
||||||
|
1.25 * scale, .65f, .75f, .84f);
|
||||||
|
Text($"RADIUS {Number(benchmark.TargetEarthRadii)} R", x + 16 * scale, y + 64 * scale,
|
||||||
|
1.25 * scale, .65f, .75f, .84f);
|
||||||
|
Text($"TIME {Time(benchmark.ElapsedSeconds)} / {Time(benchmark.DurationSeconds)}", x + 16 * scale,
|
||||||
|
y + 81 * scale, 1.18 * scale, .65f, .75f, .84f);
|
||||||
|
Text($"CURRENT {Number(benchmark.CurrentFps)} FPS", x + 16 * scale, y + 98 * scale,
|
||||||
|
1.30 * scale, .84f, .92f, 1f);
|
||||||
|
Text($"AVERAGE {Number(benchmark.AverageFps)} FPS", x + 16 * scale, y + 115 * scale,
|
||||||
|
1.20 * scale, .7f, .83f, .91f);
|
||||||
|
Text($"MIN {Number(benchmark.MinimumFps)} 1 PCT LOW {Number(benchmark.OnePercentLowFps)}",
|
||||||
|
x + 16 * scale, y + 132 * scale, 1.02 * scale, .7f, .83f, .91f);
|
||||||
|
|
||||||
|
_panel.ReplaceVertices(vertices.ToArray());
|
||||||
|
|
||||||
|
void Rect(double px, double py, double w, double h, float r, float g, float b)
|
||||||
|
{
|
||||||
|
Vertex(px, py); Vertex(px + w, py); Vertex(px + w, py + h);
|
||||||
|
Vertex(px, py); Vertex(px + w, py + h); Vertex(px, py + h);
|
||||||
|
void Vertex(double vx, double vy)
|
||||||
|
{
|
||||||
|
vertices.Add(X(vx, width)); vertices.Add(Y(vy, height)); vertices.Add(0);
|
||||||
|
vertices.Add(r); vertices.Add(g); vertices.Add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Text(string value, double px, double py, double glyphScale, float r, float g, float b)
|
||||||
|
{
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
if (PauseMenu.Font.TryGetValue(character, out var glyph))
|
||||||
|
for (var row = 0; row < glyph.Length; row++)
|
||||||
|
for (var col = 0; col < glyph[row].Length; col++)
|
||||||
|
if (glyph[row][col] == '1') Rect(px + col * glyphScale, py + row * glyphScale,
|
||||||
|
glyphScale * .9, glyphScale * .9, r, g, b);
|
||||||
|
px += 6 * glyphScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateGraph(int width, int height, IReadOnlyList<double> samples, double x, double y,
|
||||||
|
double graphWidth, double graphHeight, double scale)
|
||||||
|
{
|
||||||
|
var grid = new List<float>(1800);
|
||||||
|
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);
|
||||||
|
maximum = System.Math.Min(maximum, 240);
|
||||||
|
for (var i = 0; i <= 4; i++)
|
||||||
|
{
|
||||||
|
var py = y + graphHeight * i / 4;
|
||||||
|
AddLine(grid, width, height, x, py, x + graphWidth, py, .12f, .19f, .25f);
|
||||||
|
}
|
||||||
|
for (var i = 0; i <= 8; i++)
|
||||||
|
{
|
||||||
|
var px = x + graphWidth * i / 8;
|
||||||
|
AddLine(grid, width, height, px, y, px, y + graphHeight, .075f, .12f, .17f);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < samples.Count; i++)
|
||||||
|
{
|
||||||
|
var px = x + graphWidth * (samples.Count <= 1 ? .5 : (double)i / (samples.Count - 1));
|
||||||
|
var py = y + graphHeight * (1 - System.Math.Clamp(samples[i] / maximum, 0, 1));
|
||||||
|
curve.Add(X(px, width)); curve.Add(Y(py, height)); curve.Add(0);
|
||||||
|
curve.Add(.25f); curve.Add(.85f); curve.Add(1f);
|
||||||
|
}
|
||||||
|
_grid.ReplaceVertices(grid.ToArray());
|
||||||
|
_curve.ReplaceVertices(curve.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddLine(List<float> vertices, int width, int height, double x1, double y1, double x2, double y2,
|
||||||
|
float r, float g, float b)
|
||||||
|
{
|
||||||
|
Add(x1, y1); Add(x2, y2);
|
||||||
|
void Add(double x, double y)
|
||||||
|
{
|
||||||
|
vertices.Add(X(x, width)); vertices.Add(Y(y, height)); vertices.Add(0);
|
||||||
|
vertices.Add(r); vertices.Add(g); vertices.Add(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float X(double x, int width) => (float)(2 * x / width - 1);
|
||||||
|
private static float Y(double y, int height) => (float)(1 - 2 * y / height);
|
||||||
|
private static string Number(double value) => value.ToString("F1", CultureInfo.GetCultureInfo("sv-SE"));
|
||||||
|
private static string Time(double seconds) =>
|
||||||
|
$"{(int)(seconds / 60):00}:{(int)(seconds % 60):00}";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_panel.Dispose();
|
||||||
|
_grid.Dispose();
|
||||||
|
_curve.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Universe.App/Graphics/CloudDensityTexture.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class CloudDensityTexture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Handle { get; }
|
||||||
|
|
||||||
|
public CloudDensityTexture(GL gl, CloudDensityMap? map)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Handle = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Handle);
|
||||||
|
var samples = map?.Samples ?? [255];
|
||||||
|
fixed (byte* pixels = samples)
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R8, (uint)(map?.Width ?? 1), (uint)(map?.Height ?? 1), 0,
|
||||||
|
PixelFormat.Red, PixelType.UnsignedByte, pixels);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)(map is null ? GLEnum.Linear : GLEnum.LinearMipmapLinear));
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
|
||||||
|
if (map is not null) gl.GenerateMipmap(GLEnum.Texture2D);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _gl.DeleteTexture(Handle);
|
||||||
|
}
|
||||||
33
src/Universe.App/Graphics/CloudNoiseTexture.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class CloudNoiseTexture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Shape { get; }
|
||||||
|
public uint Detail { get; }
|
||||||
|
public CloudNoiseTexture(GL gl)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Shape = Upload(128, false);
|
||||||
|
Detail = Upload(64, true);
|
||||||
|
}
|
||||||
|
private uint Upload(int size, bool detail)
|
||||||
|
{
|
||||||
|
var handle = _gl.GenTexture();
|
||||||
|
_gl.BindTexture(GLEnum.Texture3D, handle);
|
||||||
|
var data = CloudNoise.Build(size, detail);
|
||||||
|
fixed (byte* p = data)
|
||||||
|
_gl.TexImage3D(GLEnum.Texture3D, 0, InternalFormat.Rgba8, (uint)size, (uint)size, (uint)size, 0, PixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||||||
|
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
|
||||||
|
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
|
||||||
|
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
|
||||||
|
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
|
||||||
|
_gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapR, (int)GLEnum.Repeat);
|
||||||
|
_gl.GenerateMipmap(GLEnum.Texture3D);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
public void Dispose() { _gl.DeleteTexture(Shape); _gl.DeleteTexture(Detail); }
|
||||||
|
}
|
||||||
111
src/Universe.App/Graphics/GpuMesh.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class GpuMesh : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly uint _vao;
|
||||||
|
private readonly uint _vertexBuffer;
|
||||||
|
private readonly uint _indexBuffer;
|
||||||
|
private readonly int _strideFloats;
|
||||||
|
private readonly bool _hasIndices;
|
||||||
|
private int _vertexCount;
|
||||||
|
private int _indexCount;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public GpuMesh(GL gl, int strideFloats, float[] vertices, uint[]? indices = null, bool dynamic = false)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
_strideFloats = strideFloats;
|
||||||
|
_hasIndices = indices is { Length: > 0 };
|
||||||
|
_vao = gl.GenVertexArray();
|
||||||
|
_vertexBuffer = gl.GenBuffer();
|
||||||
|
_indexBuffer = _hasIndices ? gl.GenBuffer() : 0;
|
||||||
|
gl.BindVertexArray(_vao);
|
||||||
|
gl.BindBuffer(GLEnum.ArrayBuffer, _vertexBuffer);
|
||||||
|
UploadVertices(vertices, dynamic);
|
||||||
|
|
||||||
|
if (_hasIndices)
|
||||||
|
{
|
||||||
|
gl.BindBuffer(GLEnum.ElementArrayBuffer, _indexBuffer);
|
||||||
|
UploadIndices(indices!, dynamic);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigureAttributes();
|
||||||
|
gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceVertices(float[] vertices, bool dynamic = true)
|
||||||
|
{
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
_gl.BindBuffer(GLEnum.ArrayBuffer, _vertexBuffer);
|
||||||
|
UploadVertices(vertices, dynamic);
|
||||||
|
ConfigureAttributes();
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(GLEnum primitive = GLEnum.Triangles)
|
||||||
|
{
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
if (_hasIndices)
|
||||||
|
_gl.DrawElements(primitive, (uint)_indexCount, DrawElementsType.UnsignedInt, null);
|
||||||
|
else
|
||||||
|
_gl.DrawArrays(primitive, 0, (uint)_vertexCount);
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UploadVertices(float[] vertices, bool dynamic)
|
||||||
|
{
|
||||||
|
_vertexCount = vertices.Length / _strideFloats;
|
||||||
|
fixed (float* pointer = vertices)
|
||||||
|
{
|
||||||
|
_gl.BufferData(
|
||||||
|
GLEnum.ArrayBuffer,
|
||||||
|
(nuint)(vertices.Length * sizeof(float)),
|
||||||
|
pointer,
|
||||||
|
dynamic ? GLEnum.DynamicDraw : GLEnum.StaticDraw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UploadIndices(uint[] indices, bool dynamic)
|
||||||
|
{
|
||||||
|
_indexCount = indices.Length;
|
||||||
|
fixed (uint* pointer = indices)
|
||||||
|
{
|
||||||
|
_gl.BufferData(
|
||||||
|
GLEnum.ElementArrayBuffer,
|
||||||
|
(nuint)(indices.Length * sizeof(uint)),
|
||||||
|
pointer,
|
||||||
|
dynamic ? GLEnum.DynamicDraw : GLEnum.StaticDraw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureAttributes()
|
||||||
|
{
|
||||||
|
var stride = _strideFloats * sizeof(float);
|
||||||
|
_gl.EnableVertexAttribArray(0);
|
||||||
|
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, (uint)stride, (void*)0);
|
||||||
|
if (_strideFloats >= 6)
|
||||||
|
{
|
||||||
|
_gl.EnableVertexAttribArray(1);
|
||||||
|
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
|
||||||
|
}
|
||||||
|
if (_strideFloats >= 7)
|
||||||
|
{
|
||||||
|
_gl.EnableVertexAttribArray(2);
|
||||||
|
_gl.VertexAttribPointer(2, _strideFloats >= 9 ? 3 : 1, VertexAttribPointerType.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
_gl.DeleteVertexArray(_vao);
|
||||||
|
_gl.DeleteBuffer(_vertexBuffer);
|
||||||
|
if (_indexBuffer != 0)
|
||||||
|
_gl.DeleteBuffer(_indexBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Universe.App/Graphics/GpuPassProfiler.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Non-blocking GPU timestamp measurements. Results are collected several frames later.</summary>
|
||||||
|
internal sealed class GpuPassProfiler : IDisposable
|
||||||
|
{
|
||||||
|
private const int FramesInFlight = 4;
|
||||||
|
|
||||||
|
private sealed class QuerySeries
|
||||||
|
{
|
||||||
|
public readonly uint[] Starts = new uint[FramesInFlight];
|
||||||
|
public readonly uint[] Ends = new uint[FramesInFlight];
|
||||||
|
public readonly bool[] Pending = new bool[FramesInFlight];
|
||||||
|
public readonly long[] IssuedFrame = new long[FramesInFlight];
|
||||||
|
public readonly SortedDictionary<long, double> Recent = [];
|
||||||
|
public bool HasResult;
|
||||||
|
public double LatestMilliseconds;
|
||||||
|
public long LatestFrame = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly Dictionary<string, QuerySeries> _series = new(StringComparer.Ordinal);
|
||||||
|
private readonly Stack<QuerySeries> _open = new();
|
||||||
|
private int _slot;
|
||||||
|
private long _frame;
|
||||||
|
private bool _enabled;
|
||||||
|
private bool _slotAvailable;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public GpuPassProfiler(GL gl) => _gl = gl;
|
||||||
|
|
||||||
|
public void BeginFrame(bool enabled)
|
||||||
|
{
|
||||||
|
Poll();
|
||||||
|
_frame++;
|
||||||
|
_enabled = enabled;
|
||||||
|
_slot = (int)(_frame % FramesInFlight);
|
||||||
|
_slotAvailable = enabled && !_series.Values.Any(s => s.Pending[_slot]);
|
||||||
|
_open.Clear();
|
||||||
|
if (_slotAvailable) BeginPass("GPU FRAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndFrame()
|
||||||
|
{
|
||||||
|
if (!_slotAvailable) return;
|
||||||
|
if (_open.Count != 1)
|
||||||
|
throw new InvalidOperationException($"Unbalanced GPU profiling scopes: {_open.Count - 1} render passes remain open.");
|
||||||
|
EndPass();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginPass(string name)
|
||||||
|
{
|
||||||
|
if (!_enabled || !_slotAvailable) return;
|
||||||
|
var queries = Get(name);
|
||||||
|
_gl.QueryCounter(queries.Starts[_slot], QueryCounterTarget.Timestamp);
|
||||||
|
_open.Push(queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndPass()
|
||||||
|
{
|
||||||
|
if (!_enabled || !_slotAvailable) return;
|
||||||
|
if (_open.Count == 0) throw new InvalidOperationException("GPU profiling pass ended without a matching start.");
|
||||||
|
var queries = _open.Pop();
|
||||||
|
_gl.QueryCounter(queries.Ends[_slot], QueryCounterTarget.Timestamp);
|
||||||
|
queries.Pending[_slot] = true;
|
||||||
|
queries.IssuedFrame[_slot] = _frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<KeyValuePair<string, double>> Results() => _series
|
||||||
|
.Where(p => p.Value.HasResult)
|
||||||
|
.Select(p => new KeyValuePair<string, double>(p.Key, p.Value.LatestMilliseconds))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public bool TryGetAverageDifference(string baselineName, string reducedName, out double milliseconds)
|
||||||
|
{
|
||||||
|
milliseconds = 0;
|
||||||
|
if (!_series.TryGetValue(baselineName, out var baseline) || !_series.TryGetValue(reducedName, out var reduced)) return false;
|
||||||
|
var matching = baseline.Recent.Keys.Intersect(reduced.Recent.Keys).OrderDescending().Take(4).ToArray();
|
||||||
|
if (matching.Length == 0) return false;
|
||||||
|
milliseconds = matching.Average(frame => baseline.Recent[frame] - reduced.Recent[frame]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private QuerySeries Get(string name)
|
||||||
|
{
|
||||||
|
if (_series.TryGetValue(name, out var found)) return found;
|
||||||
|
var created = new QuerySeries();
|
||||||
|
for (var i = 0; i < FramesInFlight; i++)
|
||||||
|
{
|
||||||
|
created.Starts[i] = _gl.GenQuery();
|
||||||
|
created.Ends[i] = _gl.GenQuery();
|
||||||
|
}
|
||||||
|
_series.Add(name, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Poll()
|
||||||
|
{
|
||||||
|
foreach (var queries in _series.Values)
|
||||||
|
for (var i = 0; i < FramesInFlight; i++)
|
||||||
|
{
|
||||||
|
if (!queries.Pending[i]) continue;
|
||||||
|
_gl.GetQueryObject(queries.Ends[i], GLEnum.QueryResultAvailable, out int available);
|
||||||
|
if (available == 0) continue;
|
||||||
|
_gl.GetQueryObject(queries.Starts[i], GLEnum.QueryResult, out ulong start);
|
||||||
|
_gl.GetQueryObject(queries.Ends[i], GLEnum.QueryResult, out ulong end);
|
||||||
|
if (end >= start && queries.IssuedFrame[i] >= queries.LatestFrame)
|
||||||
|
{
|
||||||
|
queries.LatestMilliseconds = (end - start) / 1_000_000d;
|
||||||
|
queries.LatestFrame = queries.IssuedFrame[i];
|
||||||
|
queries.HasResult = true;
|
||||||
|
queries.Recent[queries.IssuedFrame[i]] = queries.LatestMilliseconds;
|
||||||
|
while (queries.Recent.Count > 256) queries.Recent.Remove(queries.Recent.Keys.First());
|
||||||
|
}
|
||||||
|
queries.Pending[i] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases per-system planet query objects when the streamed system changes.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
foreach (var queries in _series.Values)
|
||||||
|
for (var i = 0; i < FramesInFlight; i++)
|
||||||
|
{
|
||||||
|
_gl.DeleteQuery(queries.Starts[i]);
|
||||||
|
_gl.DeleteQuery(queries.Ends[i]);
|
||||||
|
}
|
||||||
|
_series.Clear();
|
||||||
|
_open.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/Universe.App/Graphics/GraphicsDiagnostics.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Checks the actual GPU height field against the collision field; never reads the desktop.</summary>
|
||||||
|
internal static class GraphicsDiagnostics
|
||||||
|
{
|
||||||
|
public static unsafe void ValidatePlanetTerrain(GL gl, GpuMesh fullscreen, PlanetData planet, Action<ShaderProgram> bind)
|
||||||
|
{
|
||||||
|
const int size = 64;
|
||||||
|
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
|
||||||
|
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
|
||||||
|
"#version 330 core\n" + ShaderSources.Load("noise") + "\n" + ShaderSources.Load("planet-height") + """
|
||||||
|
in vec2 vUv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() { vec3 d=normalize(vec3(vUv*2.0-1.0,1.0)); vec3 b=bodyDirection(d); vec2 uv=planetUv(b);
|
||||||
|
fragColor=vec4(surfaceElevation(d),uv,heightMapValue(b)); }
|
||||||
|
""");
|
||||||
|
target.Bind(); gl.Disable(GLEnum.DepthTest);
|
||||||
|
program.Use(); bind(program); fullscreen.Draw();
|
||||||
|
var values = new float[size * size * 4];
|
||||||
|
fixed (float* data = values) gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
|
||||||
|
var maximumError = 0d;
|
||||||
|
var maximumUvError = 0d;
|
||||||
|
var maximumMapError = 0d;
|
||||||
|
var worst = string.Empty;
|
||||||
|
for (var y = 0; y < size; y++)
|
||||||
|
for (var x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
var d = new DVector3((x + 0.5) / size * 2 - 1, (y + 0.5) / size * 2 - 1, 1).Normalized();
|
||||||
|
var cpu = TerrainHeight.Sample(planet, d) - planet.RadiusMeters;
|
||||||
|
var gpu = values[(y * size + x) * 4] * planet.RadiusMeters;
|
||||||
|
var body = planet.Orientation.ToBody(d);
|
||||||
|
var uv = HeightMap.Coordinates(body);
|
||||||
|
var map = planet.Terrain.HeightMap!.Sample(body);
|
||||||
|
maximumUvError = System.Math.Max(maximumUvError, System.Math.Max(System.Math.Abs(uv.U - values[(y * size + x) * 4 + 1]), System.Math.Abs(uv.V - values[(y * size + x) * 4 + 2])));
|
||||||
|
maximumMapError = System.Math.Max(maximumMapError, System.Math.Abs(map - values[(y * size + x) * 4 + 3]));
|
||||||
|
if (System.Math.Abs(cpu - gpu) > maximumError)
|
||||||
|
{
|
||||||
|
maximumError = System.Math.Abs(cpu - gpu);
|
||||||
|
worst = $"pixel {x},{y}: CPU {cpu:G6} m GPU {gpu:G6} m; UV {uv.U:G6},{uv.V:G6} vs {values[(y * size + x) * 4 + 1]:G6},{values[(y * size + x) * 4 + 2]:G6}; map {map:G6} vs {values[(y * size + x) * 4 + 3]:G6}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Graphics validation: '{planet.Id}' heightmap + noise CPU/GPU error {maximumError:G6} m");
|
||||||
|
Console.WriteLine($"Map diagnostic UV error {maximumUvError:G6}, map error {maximumMapError:G6}; {worst}");
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
|
||||||
|
if (maximumError > System.Math.Max(0.5, planet.TerrainAmplitudeMeters * 0.002))
|
||||||
|
throw new InvalidOperationException($"GPU terrain for '{planet.Id}' does not match its collision heightmap.");
|
||||||
|
if (planet.Terrain.RegionalHeight is { } regional) ValidateRegionalTerrain(gl, fullscreen, planet, regional, bind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe void ValidateRegionalTerrain(GL gl, GpuMesh fullscreen, PlanetData planet, RegionalHeightMap region, Action<ShaderProgram> bind)
|
||||||
|
{
|
||||||
|
const int size = 64;
|
||||||
|
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
|
||||||
|
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
|
||||||
|
"#version 330 core\n" + ShaderSources.Load("noise") + "\n" + ShaderSources.Load("planet-height") + """
|
||||||
|
in vec2 vUv; out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
float lon=radians(mix(uRegionBounds.x,uRegionBounds.z,vUv.x));
|
||||||
|
float lat=radians(mix(uRegionBounds.y,uRegionOther.x,vUv.y));
|
||||||
|
vec3 body=vec3(cos(lat)*cos(lon),sin(lat),-cos(lat)*sin(lon));
|
||||||
|
fragColor=vec4(surfaceElevation(transpose(mat3(uWorldToBody))*body),0,0,1);
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
target.Bind(); gl.Disable(GLEnum.DepthTest); program.Use(); bind(program); fullscreen.Draw();
|
||||||
|
var pixels = new float[size * size * 4];
|
||||||
|
fixed (float* data = pixels) gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
|
||||||
|
var maximumError = 0d;
|
||||||
|
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
var lon = (region.WestDegrees + (region.EastDegrees - region.WestDegrees) * (x + .5) / size) * System.Math.PI / 180;
|
||||||
|
var lat = (region.SouthDegrees + (region.NorthDegrees - region.SouthDegrees) * (y + .5) / size) * System.Math.PI / 180;
|
||||||
|
var body = new DVector3(System.Math.Cos(lat) * System.Math.Cos(lon), System.Math.Sin(lat), -System.Math.Cos(lat) * System.Math.Sin(lon));
|
||||||
|
var direction = planet.Orientation.ToWorld(body);
|
||||||
|
var cpu = TerrainHeight.Sample(planet, direction) - planet.RadiusMeters;
|
||||||
|
maximumError = System.Math.Max(maximumError, System.Math.Abs(cpu - pixels[(y * size + x) * 4] * planet.RadiusMeters));
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Graphics validation: '{planet.Id}' regional heights CPU/GPU maximum error {maximumError:G6} m");
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
|
||||||
|
if (maximumError > 30) throw new InvalidOperationException("Regional GPU terrain differs from collision height by over 30 m.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe void ValidateTerrain(GL gl, GpuMesh fullscreen)
|
||||||
|
{
|
||||||
|
const int size = 64;
|
||||||
|
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
|
||||||
|
using var program = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
|
||||||
|
"#version 330 core\n" + ShaderSources.Load("noise") + """
|
||||||
|
in vec2 vUv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() { fragColor=vec4(terrainHeight(normalize(vec3(vUv*2.0-1.0,1.0)),vec3(3.125,17.25,6.5),9),preciseNoise3(vec3(vUv*9.0,4.7)),noise3(vec3(vUv*9.0,4.7)),1); }
|
||||||
|
""");
|
||||||
|
target.Bind();
|
||||||
|
gl.Disable(GLEnum.DepthTest);
|
||||||
|
program.Use(); program.Set("uNoise", 4);
|
||||||
|
fullscreen.Draw();
|
||||||
|
var values = new float[size * size * 4];
|
||||||
|
fixed (float* data = values)
|
||||||
|
gl.ReadPixels(0, 0, size, size, PixelFormat.Rgba, PixelType.Float, data);
|
||||||
|
var error = 0d;
|
||||||
|
var noiseError = 0d;
|
||||||
|
var filteredError = 0d;
|
||||||
|
for (var y = 0; y < size; y++)
|
||||||
|
for (var x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
var direction = new DVector3((x + 0.5) / size * 2 - 1, (y + 0.5) / size * 2 - 1, 1).Normalized();
|
||||||
|
var cpu = TerrainHeight.Elevation(direction, new(3.125, 17.25, 6.5));
|
||||||
|
error = System.Math.Max(error, System.Math.Abs(cpu - values[(y * size + x) * 4]));
|
||||||
|
var noise = ProceduralNoise.Sample(new DVector3((x + 0.5) / size * 9, (y + 0.5) / size * 9, 4.7));
|
||||||
|
noiseError = System.Math.Max(noiseError, System.Math.Abs(noise - values[(y * size + x) * 4 + 1]));
|
||||||
|
filteredError = System.Math.Max(filteredError, System.Math.Abs(noise - values[(y * size + x) * 4 + 2]));
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Graphics validation: CPU/GPU terrain maximum normalized error {error:G6}");
|
||||||
|
Console.WriteLine($"Graphics validation: precise noise error {noiseError:G6}; hardware filtered noise error {filteredError:G6}; GPU {gl.GetStringS(GLEnum.Renderer)}");
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
|
||||||
|
if (error > 0.0003) throw new InvalidOperationException("GPU terrain does not match the collision surface.");
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/Universe.App/Graphics/GraphicsReport.cs
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Numerics;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.World;
|
||||||
|
using Universe.Core.Configuration;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.App.Runtime;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Offscreen tests of the production shaders; no desktop capture or mouse control.</summary>
|
||||||
|
internal static class GraphicsReport
|
||||||
|
{
|
||||||
|
public static void RunTerrainPerformance(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
|
||||||
|
{
|
||||||
|
var system = neighborhood.ActiveSystem;
|
||||||
|
var planet = system.Planets.Single(p => p.Id == "mars");
|
||||||
|
var center = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
|
||||||
|
var camera = new FlyCamera(center, cellSize);
|
||||||
|
camera.SetInitialView(system, new StartupConfiguration { PlanetId = "mars" });
|
||||||
|
camera.RequestSurfaceView([system]);
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
var body = center.Add(planet.PositionMeters, cellSize);
|
||||||
|
var radial = camera.Position.RelativeTo(body, cellSize).Normalized();
|
||||||
|
camera.SetPlayerPose(body.Add(radial * (TerrainHeight.Sample(planet, radial) + 250), cellSize), camera.Forward, radial);
|
||||||
|
for (var i = 0; i < 600; i++)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, neighborhood, 1d / 60);
|
||||||
|
if ((i + 1) % 100 == 0) Console.WriteLine($"Terrain warmup {i + 1}: tiles {renderer.TerrainTiles}, nearest level {renderer.TerrainNearestLevel}, deepest {renderer.TerrainDeepestLevel}, pending {renderer.TerrainPendingTiles}.");
|
||||||
|
}
|
||||||
|
gl.Finish();
|
||||||
|
renderer.ProfileFrame = true;
|
||||||
|
var totals = new Dictionary<string, double>();
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, neighborhood, 1d / 60);
|
||||||
|
foreach (var (key, value) in renderer.FrameTimings) totals[key] = totals.GetValueOrDefault(key) + value / 5;
|
||||||
|
}
|
||||||
|
renderer.ProfileFrame = false;
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
WritePng(Path.Combine(directory, "terrain-performance.png"), width, height, Read(gl, width, height), false);
|
||||||
|
Console.WriteLine($"Terrain performance ({width}x{height}, synchronized): {string.Join(", ", totals.Select(p => $"{p.Key} {p.Value:F2} ms"))}; tiles {renderer.TerrainTiles}, nearest level {renderer.TerrainNearestLevel} at {renderer.TerrainNearestDistance:F1} m, deepest {renderer.TerrainDeepestLevel}, pending {renderer.TerrainPendingTiles}.");
|
||||||
|
}
|
||||||
|
public static void RunMenu(GL gl, UniverseRenderer renderer, FlyCamera camera, UniverseNeighborhood neighborhood, int width, int height)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, neighborhood, 0); renderer.DrawMenu(0, false); gl.Finish();
|
||||||
|
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Menu GL error: {error}");
|
||||||
|
var pixels = Read(gl, width, height);
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(dir);
|
||||||
|
WritePng(Path.Combine(dir, "pause-menu.png"), width, height, pixels, false);
|
||||||
|
var scale = System.Math.Min(width / 1440d, height / 900d);
|
||||||
|
if (renderer.MenuHit(width * .5, height * .5 - 152 * scale, width, height) != 0
|
||||||
|
|| 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)
|
||||||
|
throw new InvalidOperationException("Menu hit testing failed.");
|
||||||
|
Console.WriteLine($"Pause menu rendered and hit testing passed. Image: {dir}");
|
||||||
|
}
|
||||||
|
public static void RunTerrain(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
|
||||||
|
{
|
||||||
|
var system = neighborhood.ActiveSystem;
|
||||||
|
var earth = system.Planets.Single(p => p.Id == "earth");
|
||||||
|
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
|
||||||
|
var camera = new FlyCamera(origin, cellSize);
|
||||||
|
camera.SetInitialView(system, new StartupConfiguration { PlanetId = "earth", LongitudeDegrees = -7.5, LatitudeDegrees = 46.5,
|
||||||
|
DistanceInRadii = 1.003, SpeedMetersPerSecond = 1000 });
|
||||||
|
var radial = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Normalized();
|
||||||
|
var distance = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Length;
|
||||||
|
var altitude = distance - TerrainHeight.Sample(earth, radial);
|
||||||
|
var steps = (int)System.Math.Max(0, System.Math.Floor((altitude - 1000) / 100));
|
||||||
|
for (var i = 0; i < steps; i++) camera.Update(.1, camera.Forward, neighborhood);
|
||||||
|
var litSystem = system with { Stars = system.Stars.Select((s, i) => i == earth.HostStarIndex
|
||||||
|
? s with { PositionMeters = earth.PositionMeters + radial * UniverseScale.AstronomicalUnitMeters } : s).ToArray(),
|
||||||
|
Planets = system.Planets.Select(p => p.Id == earth.Id ? p with { Clouds = null, Ocean = null, OceanMask = null } : p).ToArray() };
|
||||||
|
var lit = neighborhood with { ActiveSystem = litSystem };
|
||||||
|
for (var i = 0; i < 60; i++) renderer.Render(camera, lit, .016);
|
||||||
|
renderer.Render(camera, lit, .016); gl.Finish();
|
||||||
|
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Terrain report GL error: {error}");
|
||||||
|
var textured = Read(gl, width, height);
|
||||||
|
var noMaterials = litSystem with { Planets = litSystem.Planets.Select(p => p.Id == earth.Id ? p with { Materials = p.Materials with { Enabled = false } } : p).ToArray() };
|
||||||
|
renderer.Render(camera, lit with { ActiveSystem = noMaterials }, .016); gl.Finish();
|
||||||
|
var plain = Read(gl, width, height);
|
||||||
|
var difference = textured.Zip(plain, (a, b) => System.Math.Abs(a - b)).Average();
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
WritePng(Path.Combine(directory, "earth-alps-materials.png"), width, height, textured, false);
|
||||||
|
var remaining = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Length - TerrainHeight.Sample(earth, radial);
|
||||||
|
if (difference < .0001) throw new InvalidOperationException($"Surface materials do not visibly change the near-ground scene (difference {difference:G5}, altitude {remaining:G5} m).");
|
||||||
|
Console.WriteLine($"Terrain report passed: regional terrain and visible material contribution {difference:G5}. Images: {directory}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RunClouds(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
var system = neighborhood.ActiveSystem;
|
||||||
|
var earth = system.Planets.Single(p => p.Id == "earth");
|
||||||
|
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
|
||||||
|
var camera = new FlyCamera(origin, cellSize);
|
||||||
|
camera.SetInitialView(system, new StartupConfiguration()); camera.RequestOceanView([system]);
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
var current = neighborhood;
|
||||||
|
float[] Capture(string name)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, current, .1); gl.Finish();
|
||||||
|
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Cloud report GL error: {error}");
|
||||||
|
var pixels = Read(gl, width, height);
|
||||||
|
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Cloud scene produced non-finite pixels.");
|
||||||
|
WritePng(Path.Combine(directory, name), width, height, pixels, false);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
float[] Layer()
|
||||||
|
{
|
||||||
|
var target = renderer.CloudLayer ?? throw new InvalidOperationException("Cloud volume was not rendered.");
|
||||||
|
target.Bind(); var pixels = Read(gl, target.Width, target.Height);
|
||||||
|
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Cloud volume produced non-finite radiance.");
|
||||||
|
var trans = pixels.Where((_, i) => i % 4 == 3).ToArray();
|
||||||
|
if (trans.Any(t => t < 0 || t > 1)) throw new InvalidOperationException("Invalid cloud transmittance.");
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
var day = Capture("earth-clouds-day.png"); var dayLayer = Layer();
|
||||||
|
if (dayLayer.Where((_, i) => i % 4 == 3).Min() > .98) throw new InvalidOperationException("Cloud layer has no visible density.");
|
||||||
|
var clear = system with { Planets = system.Planets.Select(p => p.Clouds is null ? p : p with { Clouds = p.Clouds with { Enabled = false } }).ToArray() };
|
||||||
|
current = neighborhood with { ActiveSystem = clear };
|
||||||
|
var clearPixels = Capture("earth-clouds-disabled.png");
|
||||||
|
if (day.Zip(clearPixels, (a, b) => System.Math.Abs(a - b)).Average() < .001) throw new InvalidOperationException("Disabling clouds did not change the scene.");
|
||||||
|
var radial = (camera.Position.RelativeTo(origin, cellSize) - earth.PositionMeters).Normalized();
|
||||||
|
var sunlight = (system.Stars[0].PositionMeters - earth.PositionMeters).Normalized();
|
||||||
|
var tangent = (sunlight - radial * DVector3.Dot(sunlight, radial)).Normalized();
|
||||||
|
var sunset = system with { Stars = system.Stars.Select((s, i) => i == earth.HostStarIndex ? s with
|
||||||
|
{ PositionMeters = earth.PositionMeters + (tangent + radial * .012).Normalized() * UniverseScale.AstronomicalUnitMeters } : s).ToArray() };
|
||||||
|
current = neighborhood with { ActiveSystem = sunset };
|
||||||
|
Capture("earth-clouds-sunset.png"); var sunsetLayer = Layer();
|
||||||
|
static double Warmth(float[] values) => values.Where((_, i) => i % 4 == 0).Sum(v => (double)v)
|
||||||
|
/ System.Math.Max(.00001, values.Where((_, i) => i % 4 == 2).Sum(v => (double)v));
|
||||||
|
var dayWarmth = Warmth(dayLayer); var sunsetWarmth = Warmth(sunsetLayer);
|
||||||
|
if (sunsetWarmth < dayWarmth * 1.1) throw new InvalidOperationException($"Cloud sunset is not warmer: day {dayWarmth}, sunset {sunsetWarmth}.");
|
||||||
|
current = neighborhood;
|
||||||
|
Capture("earth-clouds-wind-before.png"); var beforeWind = Layer();
|
||||||
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
for (var i = 0; i < 24; i++) renderer.Render(camera, current, .1);
|
||||||
|
gl.Finish(); timer.Stop(); var afterWind = Layer();
|
||||||
|
var motion = beforeWind.Where((_, i) => i % 4 == 3).Zip(afterWind.Where((_, i) => i % 4 == 3), (a, b) => System.Math.Abs(a - b)).Average();
|
||||||
|
if (motion < .000001) throw new InvalidOperationException("Cloud wind did not move the volume.");
|
||||||
|
camera.RequestCloudView([system]);
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
Capture("earth-clouds-above.png");
|
||||||
|
for (var i = 0; i < 350; i++) camera.Update(.1, -camera.Up, neighborhood);
|
||||||
|
Capture("earth-clouds-inside.png");
|
||||||
|
camera.RequestFocus([system], "earth");
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
for (var i = 0; i < 20; i++) renderer.Render(camera, current, .05);
|
||||||
|
Capture("earth-clouds-orbit.png");
|
||||||
|
var orbit = Layer(); var orbitTarget = renderer.CloudLayer!;
|
||||||
|
WritePng(Path.Combine(directory, "earth-clouds-orbit-layer.png"), orbitTarget.Width, orbitTarget.Height, orbit);
|
||||||
|
var alphaImage = orbit.Select((_, i) => i % 4 == 3 ? 1f : orbit[i / 4 * 4 + 3]).ToArray();
|
||||||
|
WritePng(Path.Combine(directory, "earth-clouds-orbit-transmission.png"), orbitTarget.Width, orbitTarget.Height, alphaImage, false);
|
||||||
|
double spatialDelta = 0; var samples = 0;
|
||||||
|
for (var y = orbitTarget.Height / 2 - 20; y < orbitTarget.Height / 2 + 20; y++)
|
||||||
|
for (var x = orbitTarget.Width / 2 - 30; x < orbitTarget.Width / 2 + 30; x++)
|
||||||
|
{
|
||||||
|
var i = (y * orbitTarget.Width + x) * 4 + 3;
|
||||||
|
spatialDelta += System.Math.Abs(orbit[i] - orbit[i + 4]); samples++;
|
||||||
|
}
|
||||||
|
spatialDelta /= samples;
|
||||||
|
if (spatialDelta > .12) throw new InvalidOperationException($"Cloud orbit view has excessive subpixel aliasing: {spatialDelta}.");
|
||||||
|
var thinCuts = 0;
|
||||||
|
for (var y = orbitTarget.Height / 2 - 95; y < orbitTarget.Height / 2 + 95; y++)
|
||||||
|
for (var x = orbitTarget.Width / 2 - 95; x < orbitTarget.Width / 2 + 95; x++)
|
||||||
|
{
|
||||||
|
var dx = x - orbitTarget.Width / 2; var dy = y - orbitTarget.Height / 2;
|
||||||
|
if (dx * dx + dy * dy > 10000) continue;
|
||||||
|
var i = (y * orbitTarget.Width + x) * 4 + 3;
|
||||||
|
if (orbit[i] > .95 && ((orbit[i - 4] < .6 && orbit[i + 4] < .6)
|
||||||
|
|| (orbit[i - orbitTarget.Width * 4] < .6 && orbit[i + orbitTarget.Width * 4] < .6))) thinCuts++;
|
||||||
|
}
|
||||||
|
if (thinCuts > 12) throw new InvalidOperationException($"Cloud depth reconstruction has thin pixel cuts: {thinCuts}.");
|
||||||
|
Console.WriteLine($"Cloud GPU report passed: sunset R/B {dayWarmth:F3} -> {sunsetWarmth:F3}, wind delta {motion:G5}, orbit variation {spatialDelta:G4}, finite ground/inside/orbit views. Full rendering {timer.Elapsed.TotalMilliseconds / 24:F1} ms/frame. Images: {directory}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RunOcean(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
var origin = UniversePosition.AtCellCenter(neighborhood.ActiveSystem.Coordinate, cellSize);
|
||||||
|
var camera = new FlyCamera(origin, cellSize);
|
||||||
|
camera.SetInitialView(neighborhood.ActiveSystem, new StartupConfiguration());
|
||||||
|
camera.RequestOceanView([neighborhood.ActiveSystem]);
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
for (var i = 0; i < 12; i++) renderer.Render(camera, neighborhood, 1d / 60);
|
||||||
|
float[] Capture(string name)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, neighborhood, .1); gl.Finish();
|
||||||
|
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Ocean report GL error: {error}");
|
||||||
|
var pixels = Read(gl, width, height);
|
||||||
|
if (pixels.Any(v => !float.IsFinite(v))) throw new InvalidOperationException("Ocean produced non-finite pixels.");
|
||||||
|
WritePng(Path.Combine(directory, name), width, height, pixels, false);
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
var before = Capture("earth-ocean.png");
|
||||||
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
for (var i = 0; i < 10; i++) renderer.Render(camera, neighborhood, .1);
|
||||||
|
gl.Finish(); timer.Stop();
|
||||||
|
var after = Capture("earth-ocean-animated.png");
|
||||||
|
var difference = before.Zip(after, (a, b) => System.Math.Abs(a - b)).Average();
|
||||||
|
if (difference < .00001) throw new InvalidOperationException("Ocean animation did not change the rendered image.");
|
||||||
|
for (var i = 0; i < 18; i++) camera.Update(.1, -camera.Up, neighborhood);
|
||||||
|
Capture("earth-underwater.png");
|
||||||
|
camera.RequestFocus([neighborhood.ActiveSystem], "earth");
|
||||||
|
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
Capture("earth-ocean-orbit.png");
|
||||||
|
Console.WriteLine($"Ocean scene passed: finite frames, wave animation delta {difference:G5}, underwater and orbit views. Full rendering {timer.Elapsed.TotalMilliseconds / 10:F1} ms/frame. Images: {directory}");
|
||||||
|
}
|
||||||
|
public static void RunScene(GL gl, UniverseRenderer renderer, UniverseNeighborhood neighborhood, double cellSize, int width, int height)
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
var system = neighborhood.ActiveSystem;
|
||||||
|
var origin = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
|
||||||
|
var camera = new FlyCamera(origin, cellSize);
|
||||||
|
camera.SetInitialView(system, new StartupConfiguration()); camera.RequestRingView([system]);
|
||||||
|
for (var i = 0; i < 900; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||||
|
void Capture(string name)
|
||||||
|
{
|
||||||
|
renderer.Render(camera, neighborhood, 1d / 60); gl.Finish();
|
||||||
|
if (gl.GetError() is var error && error != GLEnum.NoError) throw new InvalidOperationException($"Scene report GL error: {error}");
|
||||||
|
WritePng(Path.Combine(directory, name), width, height, Read(gl, width, height), false);
|
||||||
|
}
|
||||||
|
Capture("saturn-ring-scene.png");
|
||||||
|
var planet = system.Planets.Single(p => p.Id == "saturn");
|
||||||
|
var bodyCamera = planet.Orientation.ToBody(camera.Position.RelativeTo(origin, cellSize) - planet.PositionMeters);
|
||||||
|
var frame = new RingRockStreamer(new RingProfile(planet.Rings!)).Update(bodyCamera);
|
||||||
|
var rock = frame.Rocks.Where(r => r.Radius > .3).MaxBy(r => r.Radius / ((r.Position - bodyCamera).Length + 1));
|
||||||
|
var world = planet.PositionMeters + planet.Orientation.ToWorld(rock.Position);
|
||||||
|
var sun = (system.Stars[planet.HostStarIndex].PositionMeters - world).Normalized();
|
||||||
|
var side = DVector3.Cross(sun, new(0, 1, 0)).Normalized();
|
||||||
|
var radial = (sun * .62 + side * .78).Normalized();
|
||||||
|
var body = new PlanetData(0, "Diagnostic asteroid", world, rock.Radius, 0, 0, Vector3.One, Vector3.Zero, 0, 0, 0, 1) { Id = "diagnostic" };
|
||||||
|
camera.SetInitialView(system with { Planets = [body] }, new StartupConfiguration
|
||||||
|
{
|
||||||
|
PlanetId = "diagnostic",
|
||||||
|
DistanceInRadii = 3.8,
|
||||||
|
FieldOfViewDegrees = 50,
|
||||||
|
LongitudeDegrees = System.Math.Atan2(radial.Z, radial.X) * 180 / System.Math.PI,
|
||||||
|
LatitudeDegrees = System.Math.Asin(radial.Y) * 180 / System.Math.PI
|
||||||
|
});
|
||||||
|
Capture("asteroid-in-rings.png");
|
||||||
|
camera = new FlyCamera(origin.Add(new DVector3(30 * UniverseScale.AstronomicalUnitMeters, 0, 0), cellSize), cellSize);
|
||||||
|
camera.Rotate(-System.Math.PI / (2 * .0022), 0); Capture("sun-from-30au.png");
|
||||||
|
Console.WriteLine($"Integrated scene report passed: rings, asteroid close-up and sunlight at 30 AU. Images: {directory}");
|
||||||
|
}
|
||||||
|
public static void Run(GL gl, GpuMesh fullscreen, ShaderProgram rocks, ShaderProgram glow, uint emptyRing)
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "graphics-report"); Directory.CreateDirectory(directory);
|
||||||
|
const int size = 768;
|
||||||
|
using (var fieldTarget = new RenderTarget(gl, 64, 64, fullPrecision: true))
|
||||||
|
using (var field = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"), "#version 330 core\n" + ShaderSources.Load("noise") + "uniform vec3 uRockMaterial;\n" + ShaderSources.Load("asteroid-shape") + "\nin vec2 vUv;out vec4 fragColor;void main(){vec3 d=normalize(vec3(vUv*2.0-1.0,1));fragColor=vec4(asteroidPosition(d,.271),asteroidHeight(d,.271));}"))
|
||||||
|
{
|
||||||
|
fieldTarget.Bind(); gl.Disable(GLEnum.DepthTest); field.Use(); field.Set("uNoise", 4); field.Set("uRockMaterial", new Vector3(.20f, .86f, .04f)); UniverseRenderer.BindAsteroidSettings(field, new()); fullscreen.Draw();
|
||||||
|
var values = Read(gl, 64, 64); var heights = values.Where((_, i) => i % 4 == 3).ToArray();
|
||||||
|
Console.WriteLine($"Asteroid field: height min {heights.Min()}, max {heights.Max()}; GL {gl.GetError()}");
|
||||||
|
Console.WriteLine($"Asteroid neighbor delta: {Enumerable.Range(0, 64 * 63).Max(i => MathF.Abs(heights[i + 64] - heights[i]))}");
|
||||||
|
}
|
||||||
|
using var asteroid = new RenderTarget(gl, size, size, true);
|
||||||
|
using var mesh = new InstancedAsteroidMesh(gl, 6);
|
||||||
|
var rotation = Quaternion.CreateFromYawPitchRoll(.3f, -.1f, 1.25f);
|
||||||
|
mesh.Upload([0, 0, 0, 1, .62f, .61f, .58f, .271f, rotation.X, rotation.Y, rotation.Z, rotation.W]);
|
||||||
|
asteroid.Bind(); gl.ClearColor(0, 0, 0, 1); gl.ClearDepth(1); gl.DepthMask(true);
|
||||||
|
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); gl.Enable(GLEnum.DepthTest); gl.Disable(GLEnum.Blend);
|
||||||
|
rocks.Use();
|
||||||
|
rocks.SetMatrix("uViewProjection", Matrix4x4.CreatePerspectiveFieldOfView(50 * MathF.PI / 180, 1, .02f, 100000));
|
||||||
|
rocks.SetMatrix("uBodyToWorld", Matrix4x4.Identity); rocks.SetMatrix("uWorldToBody", Matrix4x4.Identity);
|
||||||
|
rocks.Set("uAnchorWorld", new Vector3(0, 0, -3.3f)); rocks.Set("uPixelScale", size / (2 * MathF.Tan(25 * MathF.PI / 180)));
|
||||||
|
rocks.Set("uFar", 100000f); rocks.Set("uRadius", 1f); rocks.Set("uCameraBody", new Vector3(0, 0, 20)); rocks.Set("uPolarScale", 1f);
|
||||||
|
rocks.Set("uNoise", 4); rocks.Set("uHasRings", 0); rocks.Set("uRingProfile", 7); rocks.Set("uRingShadowSoftness", .001f);
|
||||||
|
gl.ActiveTexture(GLEnum.Texture7); gl.BindTexture(GLEnum.Texture2D, emptyRing);
|
||||||
|
rocks.Set("uRockLod", new Vector3(600, 180, 3.5f)); rocks.Set("uRockFade", new Vector3(2600, 3500, 0));
|
||||||
|
rocks.Set("uRockMaterial", new Vector3(.20f, .86f, .04f)); rocks.Set("uRockLighting", new Vector3(.85f, .008f, 0));
|
||||||
|
rocks.Set("uLightCount", 1); rocks.Set("uLightDirections[0]", Vector3.Normalize(new(-.75f, .55f, .45f)));
|
||||||
|
rocks.Set("uLightColors[0]", Vector3.One); rocks.Set("uLightFlux[0]", 1f);
|
||||||
|
UniverseRenderer.BindAsteroidSettings(rocks, new()); mesh.Draw();
|
||||||
|
var asteroidPixels = Read(gl, size, size);
|
||||||
|
if (asteroidPixels.Any(x => !float.IsFinite(x)) || asteroidPixels.Where((_, i) => i % 4 != 3).Max() < .1)
|
||||||
|
throw new InvalidOperationException("Asteroid shader produced invalid or empty output.");
|
||||||
|
WritePng(Path.Combine(directory, "asteroid-detail.png"), size, size, asteroidPixels);
|
||||||
|
gl.Finish(); var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); gl.Finish(); timer.Stop();
|
||||||
|
Console.WriteLine($"Asteroid close-up GPU render: {timer.Elapsed.TotalMilliseconds:F2} ms at {size}x{size}.");
|
||||||
|
rocks.Set("uDebugNormals", 1); gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); WritePng(Path.Combine(directory, "asteroid-normals.png"), size, size, Read(gl, size, size));
|
||||||
|
rocks.Set("uDebugNormals", 2); gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); mesh.Draw(); WritePng(Path.Combine(directory, "asteroid-solid.png"), size, size, Read(gl, size, size)); rocks.Set("uDebugNormals", 0);
|
||||||
|
|
||||||
|
using var source = new RenderTarget(gl, size, size, true); using var target = new RenderTarget(gl, size, size);
|
||||||
|
source.Bind(); gl.DepthMask(true); gl.ClearColor(0, 0, 0, 1); gl.ClearDepth(1);
|
||||||
|
gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||||
|
target.Bind(); gl.Disable(GLEnum.DepthTest); gl.DepthMask(false); glow.Use();
|
||||||
|
gl.ActiveTexture(GLEnum.Texture0); gl.BindTexture(GLEnum.Texture2D, source.Color);
|
||||||
|
gl.ActiveTexture(GLEnum.Texture1); gl.BindTexture(GLEnum.Texture2D, source.Depth);
|
||||||
|
glow.Set("uScene", 0); glow.Set("uDepth", 1); glow.Set("uAspect", 1f); glow.Set("uStarCount", 2);
|
||||||
|
var settings = new StarGlowSettings();
|
||||||
|
for (var i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
glow.Set($"uStarPosition[{i}]", new Vector3(i == 0 ? .25f : .75f, i == 0 ? .65f : .35f, .6f));
|
||||||
|
var intensity = (float)settings.Attenuation(UniverseScale.AstronomicalUnitMeters * (i == 0 ? 1 : 30), 1);
|
||||||
|
glow.Set($"uStarShape[{i}]", new Vector3(.0015f, intensity, 1.2f / size));
|
||||||
|
glow.Set($"uStarHalo[{i}]", new Vector3(.035f, .3f, 1.2f)); glow.Set($"uStarRays[{i}]", new Vector3(.09f, 10, 70));
|
||||||
|
glow.Set($"uStarGhosts[{i}]", new Vector3(.14f, .42f, .2f)); glow.Set($"uStarColors[{i}]", i == 0 ? new Vector3(1, .94f, .8f) : new Vector3(.72f, .82f, 1));
|
||||||
|
glow.Set($"uStarRotation[{i}]", i * .35f);
|
||||||
|
}
|
||||||
|
fullscreen.Draw(); var lit = Read(gl, size, size);
|
||||||
|
float Peak(int cx, int cy)
|
||||||
|
{
|
||||||
|
var peak = 0f; for (var y = cy - 2; y <= cy + 2; y++) for (var x = cx - 2; x <= cx + 2; x++) peak = MathF.Max(peak, lit[(y * size + x) * 4]); return peak;
|
||||||
|
}
|
||||||
|
var near = Peak(size / 4, (int)(size * .65)); var far = Peak(size * 3 / 4, (int)(size * .35));
|
||||||
|
if (near < far * 2 || far < .03f) throw new InvalidOperationException($"Glow attenuation failed: near {near}, far {far}.");
|
||||||
|
WritePng(Path.Combine(directory, "sun-glow.png"), size, size, lit);
|
||||||
|
source.Bind(); gl.DepthMask(true); gl.ClearDepth(0); gl.Clear(ClearBufferMask.DepthBufferBit); gl.ClearDepth(1);
|
||||||
|
target.Bind(); gl.DepthMask(false); fullscreen.Draw(); var occluded = Read(gl, size, size);
|
||||||
|
if (occluded.Where((_, i) => i % 4 != 3).Max() > .00001f) throw new InvalidOperationException("Solar glare leaked through an occluder.");
|
||||||
|
Console.WriteLine($"Graphics report: asteroid LOD6 {IcosphereMesh.Build(6).Indices.Length / 3} triangles; glow 1 AU={near:G5}, 30 AU={far:G5}; occlusion passed. Images: {directory}");
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, 0); gl.DepthMask(true); gl.Enable(GLEnum.DepthTest);
|
||||||
|
}
|
||||||
|
private static unsafe float[] Read(GL gl, int width, int height)
|
||||||
|
{ var result = new float[width * height * 4]; fixed (float* p = result) gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.Float, p); return result; }
|
||||||
|
private static void WritePng(string path, int width, int height, float[] hdr, bool toneMap = true)
|
||||||
|
{
|
||||||
|
using var output = File.Create(path); output.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 });
|
||||||
|
var header = new byte[13]; BinaryPrimitives.WriteInt32BigEndian(header, width); BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height); header[8] = 8; header[9] = 2;
|
||||||
|
Chunk(output, "IHDR", header);
|
||||||
|
using var compressed = new MemoryStream();
|
||||||
|
using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, true))
|
||||||
|
for (var y = height - 1; y >= 0; y--)
|
||||||
|
{
|
||||||
|
zlib.WriteByte(0);
|
||||||
|
for (var x = 0; x < width; x++) for (var c = 0; c < 3; c++)
|
||||||
|
{
|
||||||
|
var value = MathF.Max(0, hdr[(y * width + x) * 4 + c]);
|
||||||
|
if (toneMap) { value *= .88f; value = System.Math.Clamp((value * (2.51f * value + .03f)) / (value * (2.43f * value + .59f) + .14f), 0, 1); value = MathF.Pow(value, 1 / 2.2f); }
|
||||||
|
zlib.WriteByte((byte)(System.Math.Clamp(value, 0, 1) * 255 + .5f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Chunk(output, "IDAT", compressed.ToArray()); Chunk(output, "IEND", []);
|
||||||
|
}
|
||||||
|
private static void Chunk(Stream output, string type, byte[] data)
|
||||||
|
{
|
||||||
|
var name = System.Text.Encoding.ASCII.GetBytes(type); var length = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(length, data.Length);
|
||||||
|
output.Write(length); output.Write(name); output.Write(data); uint crc = 0xffffffff;
|
||||||
|
foreach (var b in name.Concat(data)) { crc ^= b; for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb88320 : crc >> 1; }
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(length, ~crc); output.Write(length);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Universe.App/Graphics/InstancedAsteroidMesh.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class InstancedAsteroidMesh : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly uint _vao, _vertices, _instances, _indices;
|
||||||
|
private readonly uint _indexCount;
|
||||||
|
private int _count;
|
||||||
|
public InstancedAsteroidMesh(GL gl, int subdivision)
|
||||||
|
{
|
||||||
|
_gl = gl; var mesh = IcosphereMesh.Build(subdivision); _indexCount = (uint)mesh.Indices.Length;
|
||||||
|
_vao = gl.GenVertexArray(); _vertices = gl.GenBuffer(); _instances = gl.GenBuffer(); _indices = gl.GenBuffer();
|
||||||
|
gl.BindVertexArray(_vao); gl.BindBuffer(GLEnum.ArrayBuffer, _vertices);
|
||||||
|
fixed (float* p = mesh.Vertices) gl.BufferData(GLEnum.ArrayBuffer, (nuint)(mesh.Vertices.Length * 4), p, GLEnum.StaticDraw);
|
||||||
|
Attribute(gl, 0, 3, 24, 0); Attribute(gl, 1, 3, 24, 12);
|
||||||
|
gl.BindBuffer(GLEnum.ElementArrayBuffer, _indices);
|
||||||
|
fixed (uint* p = mesh.Indices) gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(mesh.Indices.Length * 4), p, GLEnum.StaticDraw);
|
||||||
|
ConfigureInstances(gl, _instances, 1); gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
private static void Attribute(GL gl, uint location, int count, uint stride, int offset)
|
||||||
|
{ gl.EnableVertexAttribArray(location); gl.VertexAttribPointer(location, count, VertexAttribPointerType.Float, false, stride, (void*)offset); }
|
||||||
|
internal static void ConfigureInstances(GL gl, uint buffer, uint divisor)
|
||||||
|
{
|
||||||
|
gl.BindBuffer(GLEnum.ArrayBuffer, buffer);
|
||||||
|
Attribute(gl, 2, 3, 48, 0); Attribute(gl, 3, 1, 48, 12); Attribute(gl, 4, 3, 48, 16); Attribute(gl, 5, 1, 48, 28); Attribute(gl, 6, 4, 48, 32);
|
||||||
|
for (uint i = 2; i <= 6; i++) gl.VertexAttribDivisor(i, divisor);
|
||||||
|
}
|
||||||
|
public void Upload(float[] data)
|
||||||
|
{
|
||||||
|
_count = data.Length / 12; _gl.BindBuffer(GLEnum.ArrayBuffer, _instances);
|
||||||
|
fixed (float* p = data) _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(data.Length * 4), p, GLEnum.DynamicDraw);
|
||||||
|
}
|
||||||
|
public void Draw()
|
||||||
|
{ if (_count == 0) return; _gl.BindVertexArray(_vao); _gl.DrawElementsInstanced(GLEnum.Triangles, _indexCount, DrawElementsType.UnsignedInt, null, (uint)_count); _gl.BindVertexArray(0); }
|
||||||
|
public void Dispose()
|
||||||
|
{ _gl.DeleteVertexArray(_vao); _gl.DeleteBuffer(_vertices); _gl.DeleteBuffer(_instances); _gl.DeleteBuffer(_indices); }
|
||||||
|
}
|
||||||
24
src/Universe.App/Graphics/NoiseTexture.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class NoiseTexture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Handle { get; }
|
||||||
|
public NoiseTexture(GL gl)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Handle = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture3D, Handle);
|
||||||
|
fixed (float* data = ProceduralNoise.PackedGradients)
|
||||||
|
gl.TexImage3D(GLEnum.Texture3D, 0, InternalFormat.Rgba32f, ProceduralNoise.Size, ProceduralNoise.Size, ProceduralNoise.Size, 0, PixelFormat.Rgba, PixelType.Float, data);
|
||||||
|
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMinFilter, (int)GLEnum.Linear);
|
||||||
|
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
|
||||||
|
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
|
||||||
|
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
|
||||||
|
gl.TexParameter(GLEnum.Texture3D, GLEnum.TextureWrapR, (int)GLEnum.Repeat);
|
||||||
|
}
|
||||||
|
public void Dispose() => _gl.DeleteTexture(Handle);
|
||||||
|
}
|
||||||
89
src/Universe.App/Graphics/PauseMenu.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Small resolution-independent menu using a built-in bitmap font.</summary>
|
||||||
|
internal sealed class PauseMenu : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GpuMesh _mesh;
|
||||||
|
public int Selected { get; set; }
|
||||||
|
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"];
|
||||||
|
internal static readonly Dictionary<char, string[]> Font = new()
|
||||||
|
{
|
||||||
|
['A']=["01110","10001","10001","11111","10001","10001","10001"], ['C']=["01111","10000","10000","10000","10000","10000","01111"],
|
||||||
|
['D']=["11110","10001","10001","10001","10001","10001","11110"], ['E']=["11111","10000","10000","11110","10000","10000","11111"],
|
||||||
|
['F']=["11111","10000","10000","11110","10000","10000","10000"], ['I']=["11111","00100","00100","00100","00100","00100","11111"],
|
||||||
|
['B']=["11110","10001","10001","11110","10001","10001","11110"], ['G']=["01111","10000","10000","10111","10001","10001","01111"],
|
||||||
|
['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"],
|
||||||
|
['K']=["10001","10010","10100","11000","10100","10010","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"],
|
||||||
|
['Q']=["01110","10001","10001","10001","10101","10010","01101"], ['R']=["11110","10001","10001","11110","10100","10010","10001"],
|
||||||
|
['S']=["01111","10000","10000","01110","00001","00001","11110"], ['T']=["11111","00100","00100","00100","00100","00100","00100"],
|
||||||
|
['U']=["10001","10001","10001","10001","10001","10001","01110"], ['Y']=["10001","10001","01010","00100","00100","00100","00100"],
|
||||||
|
['0']=["01110","10001","10011","10101","11001","10001","01110"], ['1']=["00100","01100","00100","00100","00100","00100","01110"],
|
||||||
|
['2']=["01110","10001","00001","00010","00100","01000","11111"], ['3']=["11110","00001","00001","01110","00001","00001","11110"],
|
||||||
|
['4']=["00010","00110","01010","10010","11111","00010","00010"], ['5']=["11111","10000","10000","11110","00001","00001","11110"],
|
||||||
|
['6']=["01110","10000","10000","11110","10001","10001","01110"], ['7']=["11111","00001","00010","00100","01000","01000","01000"],
|
||||||
|
['8']=["01110","10001","10001","01110","10001","10001","01110"], ['9']=["01110","10001","10001","01111","00001","00001","01110"],
|
||||||
|
[':']=["00000","00110","00110","00000","00110","00110","00000"], ['.']=["00000","00000","00000","00000","00000","00110","00110"],
|
||||||
|
[',']=["00000","00000","00000","00000","00110","00110","00100"], ['/']=["00001","00010","00010","00100","01000","01000","10000"],
|
||||||
|
['+']=["00000","00100","00100","11111","00100","00100","00000"], ['-']=["00000","00000","00000","11111","00000","00000","00000"]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (double Scale, double X, double Y) Layout(int width, int height)
|
||||||
|
{
|
||||||
|
var s = System.Math.Min(width / 1440d, height / 900d);
|
||||||
|
return (s, width / 2d, height / 2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Hit(double x, double y, int width, int height)
|
||||||
|
{
|
||||||
|
var (s, cx, cy) = Layout(width, height);
|
||||||
|
if (s <= 0 || x < cx - 190 * s || x > cx + 190 * s) return -1;
|
||||||
|
for (var i = 0; i < Labels.Length; i++)
|
||||||
|
if (y >= cy + (-174 + i * 57) * s && y <= cy + (-130 + i * 57) * s) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(int width, int height, bool debugEnabled)
|
||||||
|
{
|
||||||
|
var (s, cx, cy) = Layout(width, height);
|
||||||
|
var data = new List<float>();
|
||||||
|
void Rect(double x, double y, double w, double h, float r, float g, float b)
|
||||||
|
{
|
||||||
|
float X(double n) => (float)(2 * n / width - 1);
|
||||||
|
float Y(double n) => (float)(1 - 2 * n / height);
|
||||||
|
void V(double px, double py) { data.Add(X(px)); data.Add(Y(py)); data.Add(0); data.Add(r); data.Add(g); data.Add(b); }
|
||||||
|
V(x,y); V(x+w,y); V(x+w,y+h); V(x,y); V(x+w,y+h); V(x,y+h);
|
||||||
|
}
|
||||||
|
void Text(string text, double centerX, double y, double unit, float r, float g, float b)
|
||||||
|
{
|
||||||
|
var x = centerX - text.Length * 6 * unit / 2;
|
||||||
|
foreach (var c in text)
|
||||||
|
{
|
||||||
|
if (Font.TryGetValue(c, out var glyph))
|
||||||
|
for (var row = 0; row < 7; row++) for (var col = 0; col < 5; col++)
|
||||||
|
if (glyph[row][col] == '1') Rect(x + col * unit, y + row * unit, unit * .9, unit * .9, r, g, b);
|
||||||
|
x += 6 * unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rect(cx - 230*s, cy - 260*s, 460*s, 520*s, .025f, .04f, .075f);
|
||||||
|
Rect(cx - 230*s, cy - 260*s, 460*s, 4*s, .25f, .65f, .9f);
|
||||||
|
Text("PAUSED", cx, cy - 226*s, 5*s, .9f, .95f, 1);
|
||||||
|
for (var i = 0; i < Labels.Length; i++)
|
||||||
|
{
|
||||||
|
var selected = i == Selected;
|
||||||
|
var y = cy + (-174 + i * 57) * s;
|
||||||
|
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);
|
||||||
|
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("P OR ESC TO RESUME", cx, cy + 220*s, 2*s, .6f, .7f, .8f);
|
||||||
|
_mesh.ReplaceVertices(data.ToArray()); _mesh.Draw(GLEnum.Triangles);
|
||||||
|
}
|
||||||
|
public void Dispose() => _mesh.Dispose();
|
||||||
|
}
|
||||||
113
src/Universe.App/Graphics/PerformanceOverlay.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed class PerformanceOverlay : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GpuMesh _mesh;
|
||||||
|
|
||||||
|
public PerformanceOverlay(GL gl) => _mesh = new(gl, 6, [], dynamic: true);
|
||||||
|
|
||||||
|
public void Draw(int width, int height, IReadOnlyList<KeyValuePair<string, double>> samples,
|
||||||
|
double cpuMilliseconds, double fps, int terrainTiles, int terrainLod, int pendingTiles, string? probePlanetId)
|
||||||
|
{
|
||||||
|
var values = samples.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
||||||
|
var rows = new List<(string Label, string Value, bool Emphasis)>();
|
||||||
|
AddMetric("GPU FRAME", "GPU FRAME", true);
|
||||||
|
AddMetric("SKY / STARFIELD", "SKY / STARFIELD");
|
||||||
|
AddMetric("PLANETS TOTAL", "PLANETS TOTAL");
|
||||||
|
foreach (var key in values.Keys.Where(k => k.StartsWith("PLANET ", StringComparison.Ordinal) && k != "PLANETS TOTAL" && k != "GPU FRAME")
|
||||||
|
.OrderBy(k => k, StringComparer.Ordinal))
|
||||||
|
AddMetric(key, key);
|
||||||
|
if (probePlanetId is { } probeId)
|
||||||
|
{
|
||||||
|
var prefix = probeId.ToUpperInvariant();
|
||||||
|
AddSaving($"{prefix} SAVE MATERIALS", "SAVE MATERIALS");
|
||||||
|
AddSaving($"{prefix} SAVE CLOUD SHADOWS", "SAVE CLOUD SHADOWS");
|
||||||
|
AddSaving($"{prefix} SAVE MOUNTAIN SHADOWS", "SAVE MOUNTAIN SHADOWS");
|
||||||
|
AddSaving($"{prefix} SAVE BODY SHADOWS", "SAVE BODY SHADOWS");
|
||||||
|
AddSaving($"{prefix} SAVE SURFACE NOISE", "SAVE SURFACE NOISE");
|
||||||
|
}
|
||||||
|
AddMetric("STARS", "STARS");
|
||||||
|
AddMetric("GAS GIANTS", "GAS GIANTS");
|
||||||
|
AddMetric("RINGS / ROCKS", "RINGS / ROCKS");
|
||||||
|
AddMetric("OCEAN", "OCEAN");
|
||||||
|
AddMetric("ATMOSPHERE", "ATMOSPHERE");
|
||||||
|
AddMetric("CLOUDS / COMPOSITE", "CLOUDS / COMPOSITE");
|
||||||
|
AddMetric("SUN GLOW", "SUN GLOW");
|
||||||
|
AddMetric("BLOOM / POST", "BLOOM / POST");
|
||||||
|
rows.Add(("SHADOWS IN PLANET", "SEE PLANET MS", false));
|
||||||
|
rows.Add(("CPU SUBMIT", $"{Number(cpuMilliseconds)} MS", false));
|
||||||
|
rows.Add(("FPS", $"{Number(fps, "F1")}", false));
|
||||||
|
rows.Add(("TERRAIN TILES", terrainTiles.ToString(CultureInfo.InvariantCulture), false));
|
||||||
|
rows.Add(("LOD / PENDING", $"{terrainLod} / {pendingTiles}", false));
|
||||||
|
|
||||||
|
var scale = System.Math.Clamp(System.Math.Min(width / 1440d, height / 900d), .58, 1.25);
|
||||||
|
var x = 14 * scale;
|
||||||
|
var top = 14 * scale;
|
||||||
|
var panelWidth = 438 * scale;
|
||||||
|
var rowHeight = 17 * scale;
|
||||||
|
var unit = 1.65 * scale;
|
||||||
|
var panelHeight = 42 * scale + rowHeight * rows.Count + 20 * scale;
|
||||||
|
var data = new List<float>(12000);
|
||||||
|
|
||||||
|
void Rect(double px, double py, double w, double h, float r, float g, float b)
|
||||||
|
{
|
||||||
|
float X(double n) => (float)(2 * n / width - 1);
|
||||||
|
float Y(double n) => (float)(1 - 2 * n / height);
|
||||||
|
void Vertex(double vx, double vy) { data.Add(X(vx)); data.Add(Y(vy)); data.Add(0); data.Add(r); data.Add(g); data.Add(b); }
|
||||||
|
Vertex(px, py); Vertex(px + w, py); Vertex(px + w, py + h);
|
||||||
|
Vertex(px, py); Vertex(px + w, py + h); Vertex(px, py + h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Text(string value, double px, double py, double glyphScale, float r, float g, float b)
|
||||||
|
{
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
if (PauseMenu.Font.TryGetValue(c, out var glyph))
|
||||||
|
for (var row = 0; row < glyph.Length; row++)
|
||||||
|
for (var col = 0; col < glyph[row].Length; col++)
|
||||||
|
if (glyph[row][col] == '1') Rect(px + col * glyphScale, py + row * glyphScale,
|
||||||
|
glyphScale * .9, glyphScale * .9, r, g, b);
|
||||||
|
px += 6 * glyphScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect(x, top, panelWidth, panelHeight, .018f, .026f, .045f);
|
||||||
|
Rect(x, top, 3 * scale, panelHeight, .13f, .48f, .72f);
|
||||||
|
Text("DEBUG / GPU MS", x + 16 * scale, top + 10 * scale, unit, .72f, .88f, 1f);
|
||||||
|
Text("ASYNC; PROBE 1 / 8 FRAMES", x + 16 * scale, top + 26 * scale, unit * .8, .40f, .60f, .72f);
|
||||||
|
var y = top + 49 * scale;
|
||||||
|
foreach (var (label, value, emphasis) in rows)
|
||||||
|
{
|
||||||
|
var color = emphasis ? (R: .55f, G: .88f, B: 1f) : (R: .78f, G: .84f, B: .9f);
|
||||||
|
Text(label, x + 16 * scale, y, unit, color.R, color.G, color.B);
|
||||||
|
var valueX = x + panelWidth - (value.Length * 6 * unit) - 14 * scale;
|
||||||
|
Text(value, valueX, y, unit, emphasis ? .9f : .58f, emphasis ? .95f : .7f, emphasis ? 1f : .78f);
|
||||||
|
y += rowHeight;
|
||||||
|
}
|
||||||
|
Text("SAVINGS ARE DIFFERENTIAL", x + 16 * scale, top + panelHeight - 15 * scale,
|
||||||
|
unit * .72, .36f, .52f, .65f);
|
||||||
|
|
||||||
|
_mesh.ReplaceVertices(data.ToArray());
|
||||||
|
_mesh.Draw(GLEnum.Triangles);
|
||||||
|
|
||||||
|
void AddMetric(string label, string key, bool emphasis = false)
|
||||||
|
{
|
||||||
|
rows.Add((label, values.TryGetValue(key, out var time) ? $"{Number(time)} MS" : "-- MS", emphasis));
|
||||||
|
}
|
||||||
|
void AddSaving(string label, string key)
|
||||||
|
{
|
||||||
|
var value = values.TryGetValue(key, out var delta)
|
||||||
|
? $"{(delta >= 0 ? "+" : "-")}{Number(System.Math.Abs(delta))} MS"
|
||||||
|
: "-- MS";
|
||||||
|
rows.Add((label, value, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Number(double value, string format = "F2") =>
|
||||||
|
value.ToString(format, CultureInfo.GetCultureInfo("sv-SE"));
|
||||||
|
|
||||||
|
public void Dispose() => _mesh.Dispose();
|
||||||
|
}
|
||||||
58
src/Universe.App/Graphics/PlanetTextures.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class PlanetTextures : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Color { get; }
|
||||||
|
public uint Height { get; }
|
||||||
|
public uint OceanMask { get; }
|
||||||
|
public uint RegionalHeight { get; }
|
||||||
|
|
||||||
|
public PlanetTextures(GL gl, PlanetData planet)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Color = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Color);
|
||||||
|
var image = planet.SurfaceTexture;
|
||||||
|
var pixels = image?.Rgba ?? [255, 255, 255, 255];
|
||||||
|
fixed (byte* data = pixels)
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.Srgb8Alpha8, (uint)(image?.Width ?? 1), (uint)(image?.Height ?? 1), 0, PixelFormat.Rgba, PixelType.UnsignedByte, data);
|
||||||
|
SetParameters(gl, true);
|
||||||
|
gl.GenerateMipmap(GLEnum.Texture2D);
|
||||||
|
Height = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Height);
|
||||||
|
var map = planet.Terrain.HeightMap;
|
||||||
|
var heights = map?.Samples ?? [0f];
|
||||||
|
fixed (float* data = heights)
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(map?.Width ?? 1), (uint)(map?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
|
||||||
|
SetParameters(gl, false);
|
||||||
|
RegionalHeight = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, RegionalHeight);
|
||||||
|
var regional = planet.Terrain.RegionalHeight;
|
||||||
|
var regionalSamples = regional?.Meters ?? [0f];
|
||||||
|
fixed (float* data = regionalSamples)
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(regional?.Width ?? 1), (uint)(regional?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
|
||||||
|
SetParameters(gl, false);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
|
||||||
|
OceanMask = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, OceanMask);
|
||||||
|
var mask = planet.OceanMask;
|
||||||
|
var samples = mask?.Samples ?? [0f];
|
||||||
|
fixed (float* data = samples)
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.R32f, (uint)(mask?.Width ?? 1), (uint)(mask?.Height ?? 1), 0, PixelFormat.Red, PixelType.Float, data);
|
||||||
|
SetParameters(gl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetParameters(GL gl, bool mipmaps)
|
||||||
|
{
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)(mipmaps ? GLEnum.LinearMipmapLinear : GLEnum.Linear));
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _gl.DeleteTexture(Color); _gl.DeleteTexture(Height); _gl.DeleteTexture(OceanMask); _gl.DeleteTexture(RegionalHeight); }
|
||||||
|
}
|
||||||
67
src/Universe.App/Graphics/PrimitiveMeshes.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal static class PrimitiveMeshes
|
||||||
|
{
|
||||||
|
public static (float[] Vertices, uint[] Indices) Ring(float innerRadius, float outerRadius, int segments = 384)
|
||||||
|
{
|
||||||
|
var vertices = new float[(segments + 1) * 2 * 6];
|
||||||
|
var indices = new uint[segments * 6];
|
||||||
|
for (var i = 0; i <= segments; i++)
|
||||||
|
{
|
||||||
|
var angle = i * System.Math.Tau / segments;
|
||||||
|
for (var edge = 0; edge < 2; edge++)
|
||||||
|
{
|
||||||
|
var r = edge == 0 ? innerRadius : outerRadius;
|
||||||
|
var v = (i * 2 + edge) * 6;
|
||||||
|
vertices[v] = (float)System.Math.Cos(angle) * r;
|
||||||
|
vertices[v + 2] = (float)System.Math.Sin(angle) * r;
|
||||||
|
vertices[v + 4] = 1;
|
||||||
|
}
|
||||||
|
if (i == segments) continue;
|
||||||
|
var n = (uint)(i * 2); var offset = i * 6;
|
||||||
|
indices[offset] = n; indices[offset + 1] = n + 1; indices[offset + 2] = n + 2;
|
||||||
|
indices[offset + 3] = n + 1; indices[offset + 4] = n + 3; indices[offset + 5] = n + 2;
|
||||||
|
}
|
||||||
|
return (vertices, indices);
|
||||||
|
}
|
||||||
|
public static (float[] Vertices, uint[] Indices) UnitSphere(int latitudeSegments = 24, int longitudeSegments = 32)
|
||||||
|
{
|
||||||
|
var vertices = new float[(latitudeSegments + 1) * (longitudeSegments + 1) * 6];
|
||||||
|
var indices = new uint[latitudeSegments * longitudeSegments * 6];
|
||||||
|
var write = 0;
|
||||||
|
for (var latitude = 0; latitude <= latitudeSegments; latitude++)
|
||||||
|
{
|
||||||
|
var phi = System.Math.PI * latitude / latitudeSegments;
|
||||||
|
var y = System.Math.Cos(phi);
|
||||||
|
var ring = System.Math.Sin(phi);
|
||||||
|
for (var longitude = 0; longitude <= longitudeSegments; longitude++)
|
||||||
|
{
|
||||||
|
var theta = System.Math.Tau * longitude / longitudeSegments;
|
||||||
|
var x = ring * System.Math.Cos(theta);
|
||||||
|
var z = ring * System.Math.Sin(theta);
|
||||||
|
vertices[write++] = (float)x;
|
||||||
|
vertices[write++] = (float)y;
|
||||||
|
vertices[write++] = (float)z;
|
||||||
|
vertices[write++] = (float)x;
|
||||||
|
vertices[write++] = (float)y;
|
||||||
|
vertices[write++] = (float)z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write = 0;
|
||||||
|
var row = longitudeSegments + 1;
|
||||||
|
for (var latitude = 0; latitude < latitudeSegments; latitude++)
|
||||||
|
for (var longitude = 0; longitude < longitudeSegments; longitude++)
|
||||||
|
{
|
||||||
|
var topLeft = (uint)(latitude * row + longitude);
|
||||||
|
var bottomLeft = topLeft + (uint)row;
|
||||||
|
indices[write++] = topLeft;
|
||||||
|
indices[write++] = bottomLeft;
|
||||||
|
indices[write++] = topLeft + 1;
|
||||||
|
indices[write++] = topLeft + 1;
|
||||||
|
indices[write++] = bottomLeft;
|
||||||
|
indices[write++] = bottomLeft + 1;
|
||||||
|
}
|
||||||
|
return (vertices, indices);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Universe.App/Graphics/RenderTarget.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class RenderTarget : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Framebuffer { get; }
|
||||||
|
public uint Color { get; }
|
||||||
|
public uint Depth { get; }
|
||||||
|
public int Width { get; }
|
||||||
|
public int Height { get; }
|
||||||
|
|
||||||
|
public RenderTarget(GL gl, int width, int height, bool depth = false, bool fullPrecision = false)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Width = width;
|
||||||
|
Height = height;
|
||||||
|
Framebuffer = gl.GenFramebuffer();
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer);
|
||||||
|
Color = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Color);
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, fullPrecision ? InternalFormat.Rgba32f : InternalFormat.Rgba16f, (uint)width, (uint)height, 0, PixelFormat.Rgba, PixelType.Float, null);
|
||||||
|
SetParameters(gl, GLEnum.Linear);
|
||||||
|
gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.ColorAttachment0, GLEnum.Texture2D, Color, 0);
|
||||||
|
if (depth)
|
||||||
|
{
|
||||||
|
Depth = gl.GenTexture();
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Depth);
|
||||||
|
gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.DepthComponent32f, (uint)width, (uint)height, 0, PixelFormat.DepthComponent, PixelType.Float, null);
|
||||||
|
SetParameters(gl, GLEnum.Nearest);
|
||||||
|
gl.FramebufferTexture2D(GLEnum.Framebuffer, GLEnum.DepthAttachment, GLEnum.Texture2D, Depth, 0);
|
||||||
|
}
|
||||||
|
var status = gl.CheckFramebufferStatus(GLEnum.Framebuffer);
|
||||||
|
if (status != GLEnum.FramebufferComplete) throw new InvalidOperationException($"Incomplete HDR framebuffer: {status}");
|
||||||
|
gl.BindFramebuffer(GLEnum.Framebuffer, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind()
|
||||||
|
{
|
||||||
|
_gl.BindFramebuffer(GLEnum.Framebuffer, Framebuffer);
|
||||||
|
_gl.Viewport(0, 0, (uint)Width, (uint)Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetParameters(GL gl, GLEnum filter)
|
||||||
|
{
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)filter);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)filter);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.ClampToEdge);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.ClampToEdge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gl.DeleteTexture(Color);
|
||||||
|
if (Depth != 0) _gl.DeleteTexture(Depth);
|
||||||
|
_gl.DeleteFramebuffer(Framebuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/Universe.App/Graphics/RingParticles.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Only local debris cells are stored. Geodesic mesh LODs depend on projected size.</summary>
|
||||||
|
internal sealed unsafe class RingParticles : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly RingRockStreamer _stream;
|
||||||
|
private readonly RingRockSettings _settings;
|
||||||
|
private readonly uint _pointVao, _pointInstances;
|
||||||
|
private readonly Dictionary<int, InstancedAsteroidMesh> _meshes = [];
|
||||||
|
private readonly Dictionary<int, ulong> _signatures = [];
|
||||||
|
private RingRock[] _candidates = [];
|
||||||
|
private int[] _previousLevels = [];
|
||||||
|
private int _revision = -1, _pointCount;
|
||||||
|
private DVector3? _lastCamera;
|
||||||
|
private double _lastPixelScale;
|
||||||
|
public DVector3 Anchor { get; private set; }
|
||||||
|
public int Count => _pointCount;
|
||||||
|
public int HighDetailCount { get; private set; }
|
||||||
|
public RingParticles(GL gl, RingProfile profile)
|
||||||
|
{
|
||||||
|
_gl = gl; _stream = new(profile); _settings = profile.Rings.Rocks;
|
||||||
|
_pointVao = gl.GenVertexArray(); _pointInstances = gl.GenBuffer();
|
||||||
|
gl.BindVertexArray(_pointVao); InstancedAsteroidMesh.ConfigureInstances(gl, _pointInstances, 0); gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
public void Update(DVector3 camera, double pixelScale)
|
||||||
|
{
|
||||||
|
var frame = _stream.Update(camera);
|
||||||
|
var changed = frame.Revision != _revision;
|
||||||
|
if (!changed && _lastCamera == camera && _lastPixelScale == pixelScale) return;
|
||||||
|
_lastCamera = camera; _lastPixelScale = pixelScale;
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
_revision = frame.Revision; Anchor = frame.Anchor; _pointCount = frame.Rocks.Count;
|
||||||
|
var points = new List<float>(_pointCount * 12); var candidates = new List<RingRock>();
|
||||||
|
var meshRange = _settings.MeshDistanceMeters + _settings.LodTransitionMeters + _settings.CellSizeMeters * 2;
|
||||||
|
foreach (var rock in frame.Rocks)
|
||||||
|
{
|
||||||
|
Pack(points, rock);
|
||||||
|
if ((rock.Position - camera).Length < meshRange) candidates.Add(rock);
|
||||||
|
}
|
||||||
|
_candidates = candidates.ToArray(); _previousLevels = new int[_candidates.Length];
|
||||||
|
_gl.BindBuffer(GLEnum.ArrayBuffer, _pointInstances);
|
||||||
|
var data = points.ToArray(); fixed (float* p = data) _gl.BufferData(GLEnum.ArrayBuffer, (nuint)(data.Length * 4), p, GLEnum.DynamicDraw);
|
||||||
|
}
|
||||||
|
var detail = _settings.Asteroid;
|
||||||
|
var entries = new List<(int Index, int Level, double Pixels)>(_candidates.Length);
|
||||||
|
for (var i = 0; i < _candidates.Length; i++)
|
||||||
|
{
|
||||||
|
var rock = _candidates[i]; var distance = (rock.Position - camera).Length;
|
||||||
|
var pixels = 2 * rock.Radius * pixelScale / System.Math.Max(.05, distance);
|
||||||
|
var level = AsteroidLod.Select(pixels, distance, detail);
|
||||||
|
var previous = _previousLevels[i];
|
||||||
|
if (previous != 0 && System.Math.Abs(level - previous) == 1)
|
||||||
|
{
|
||||||
|
var boundary = detail.LodStartPixels * System.Math.Pow(detail.LodPixelMultiplier, System.Math.Min(level, previous) - detail.BaseSubdivision);
|
||||||
|
if (pixels > boundary * .88 && pixels < boundary * 1.12 && distance <= detail.HighDetailDistanceMeters) level = previous;
|
||||||
|
}
|
||||||
|
entries.Add((i, level, pixels));
|
||||||
|
}
|
||||||
|
entries.Sort((a, b) => { var result = b.Pixels.CompareTo(a.Pixels); return result != 0 ? result : a.Index.CompareTo(b.Index); });
|
||||||
|
HighDetailCount = 0;
|
||||||
|
var buckets = new Dictionary<int, List<float>>(); var signatures = new Dictionary<int, ulong>();
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var level = entry.Level;
|
||||||
|
if (level > 3 && HighDetailCount++ >= detail.MaxHighDetailInstances) level = 3;
|
||||||
|
_previousLevels[entry.Index] = level;
|
||||||
|
if (!buckets.TryGetValue(level, out var list)) { buckets[level] = list = []; signatures[level] = 14695981039346656037UL; }
|
||||||
|
Pack(list, _candidates[entry.Index]);
|
||||||
|
signatures[level] = unchecked((signatures[level] ^ (ulong)entry.Index) * 1099511628211UL);
|
||||||
|
}
|
||||||
|
HighDetailCount = System.Math.Min(HighDetailCount, detail.MaxHighDetailInstances);
|
||||||
|
foreach (var level in _meshes.Keys.Union(buckets.Keys).ToArray())
|
||||||
|
{
|
||||||
|
var signature = signatures.GetValueOrDefault(level);
|
||||||
|
if (!changed && _signatures.TryGetValue(level, out var prior) && prior == signature) continue;
|
||||||
|
if (!_meshes.TryGetValue(level, out var mesh)) _meshes.Add(level, mesh = new(_gl, level));
|
||||||
|
mesh.Upload(buckets.TryGetValue(level, out var list) ? list.ToArray() : []); _signatures[level] = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void Pack(List<float> target, RingRock rock)
|
||||||
|
{
|
||||||
|
var p = rock.Position - Anchor;
|
||||||
|
target.AddRange([(float)p.X,(float)p.Y,(float)p.Z,(float)rock.Radius,rock.Color.X,rock.Color.Y,rock.Color.Z,rock.ShapeSeed,
|
||||||
|
rock.Rotation.X,rock.Rotation.Y,rock.Rotation.Z,rock.Rotation.W]);
|
||||||
|
}
|
||||||
|
public void DrawMeshes() { foreach (var mesh in _meshes.Values) mesh.Draw(); }
|
||||||
|
public void DrawPoints() { if (_pointCount == 0) return; _gl.BindVertexArray(_pointVao); _gl.DrawArrays(GLEnum.Points, 0, (uint)_pointCount); _gl.BindVertexArray(0); }
|
||||||
|
public void Dispose() { foreach (var mesh in _meshes.Values) mesh.Dispose(); _gl.DeleteVertexArray(_pointVao); _gl.DeleteBuffer(_pointInstances); }
|
||||||
|
}
|
||||||
43
src/Universe.App/Graphics/RingProfileTexture.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class RingProfileTexture : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
public uint Handle { get; }
|
||||||
|
public RingProfile? Profile { get; }
|
||||||
|
public RingProfileTexture(GL gl, PlanetRings? rings)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Profile = rings is null ? null : new(rings);
|
||||||
|
var data = Profile?.Rgba ?? new float[4];
|
||||||
|
var maxWidth = gl.GetInteger(GLEnum.MaxTextureSize);
|
||||||
|
if (data.Length / 4 > maxWidth)
|
||||||
|
{
|
||||||
|
var source = data;
|
||||||
|
var sourceWidth = source.Length / 4;
|
||||||
|
data = new float[maxWidth * 4];
|
||||||
|
for (var x = 0; x < maxWidth; x++)
|
||||||
|
{
|
||||||
|
var first = (int)((long)x * sourceWidth / maxWidth);
|
||||||
|
var last = (int)((long)(x + 1) * sourceWidth / maxWidth);
|
||||||
|
for (var i = first; i < last; i++)
|
||||||
|
for (var channel = 0; channel < 4; channel++)
|
||||||
|
data[x * 4 + channel] += source[i * 4 + channel] / (last - first);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Ring profile reduced from {sourceWidth} to GPU limit {maxWidth} texels.");
|
||||||
|
}
|
||||||
|
Handle = gl.GenTexture();
|
||||||
|
gl.ActiveTexture(GLEnum.Texture7);
|
||||||
|
gl.BindTexture(GLEnum.Texture2D, Handle);
|
||||||
|
fixed (float* p = data) gl.TexImage2D(GLEnum.Texture2D, 0, InternalFormat.Rgba16f, (uint)(data.Length / 4), 1, 0, PixelFormat.Rgba, PixelType.Float, p);
|
||||||
|
gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
|
||||||
|
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);
|
||||||
|
gl.GenerateMipmap(GLEnum.Texture2D);
|
||||||
|
}
|
||||||
|
public void Dispose() => _gl.DeleteTexture(Handle);
|
||||||
|
}
|
||||||
96
src/Universe.App/Graphics/ShaderProgram.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal sealed unsafe class ShaderProgram : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
private ShaderProgram(GL gl, uint handle)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
Handle = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public uint Handle { get; }
|
||||||
|
|
||||||
|
public static ShaderProgram Create(GL gl, string vertexSource, string fragmentSource)
|
||||||
|
{
|
||||||
|
var vertex = Compile(gl, ShaderType.VertexShader, vertexSource);
|
||||||
|
var fragment = Compile(gl, ShaderType.FragmentShader, fragmentSource);
|
||||||
|
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);
|
||||||
|
gl.DeleteProgram(program);
|
||||||
|
throw new InvalidOperationException($"OpenGL shader link failed: {log}");
|
||||||
|
}
|
||||||
|
return new(gl, program);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Use() => _gl.UseProgram(Handle);
|
||||||
|
|
||||||
|
public void Set(string name, int value) => _gl.Uniform1(Location(name), value);
|
||||||
|
public void Set(string name, float value) => _gl.Uniform1(Location(name), value);
|
||||||
|
public void Set(string name, Vector3 value) => _gl.Uniform3(Location(name), value.X, value.Y, value.Z);
|
||||||
|
|
||||||
|
public void SetMatrix(string name, Matrix4x4 value)
|
||||||
|
{
|
||||||
|
var location = Location(name);
|
||||||
|
// System.Numerics stores row-vector matrices in row-major order. OpenGL consumes
|
||||||
|
// column-major data, so the same memory represents the required transposed matrix.
|
||||||
|
_gl.UniformMatrix4(location, 1, false, (float*)&value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetVectorArray(string name, IReadOnlyList<Vector3> values)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < values.Count; i++)
|
||||||
|
Set($"{name}[{i}]", values[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetFloatArray(string name, IReadOnlyList<float> values)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < values.Count; i++)
|
||||||
|
Set($"{name}[{i}]", values[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Location(string name)
|
||||||
|
{
|
||||||
|
if (!_uniformLocations.TryGetValue(name, out var location))
|
||||||
|
{
|
||||||
|
location = _gl.GetUniformLocation(Handle, name);
|
||||||
|
_uniformLocations.Add(name, location);
|
||||||
|
}
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Compile(GL gl, ShaderType type, string source)
|
||||||
|
{
|
||||||
|
var shader = gl.CreateShader(type);
|
||||||
|
gl.ShaderSource(shader, source);
|
||||||
|
gl.CompileShader(shader);
|
||||||
|
gl.GetShader(shader, GLEnum.CompileStatus, out var compiled);
|
||||||
|
if (compiled != 0)
|
||||||
|
return shader;
|
||||||
|
var log = gl.GetShaderInfoLog(shader);
|
||||||
|
gl.DeleteShader(shader);
|
||||||
|
throw new InvalidOperationException($"OpenGL {type} compilation failed: {log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
_gl.DeleteProgram(Handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Universe.App/Graphics/ShaderSources.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
internal static class ShaderSources
|
||||||
|
{
|
||||||
|
public static string Load(string name)
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
var sourcePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../Graphics/Shaders", name + ".glsl"));
|
||||||
|
if (File.Exists(sourcePath))
|
||||||
|
return Expand(name, File.ReadAllText(sourcePath));
|
||||||
|
#endif
|
||||||
|
using var stream = typeof(ShaderSources).Assembly.GetManifestResourceStream($"Universe.App.Graphics.Shaders.{name}.glsl")
|
||||||
|
?? throw new FileNotFoundException($"Embedded shader {name} is missing.");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var source = reader.ReadToEnd();
|
||||||
|
return Expand(name, source);
|
||||||
|
}
|
||||||
|
private static string Expand(string name, string source)
|
||||||
|
{
|
||||||
|
foreach (var include in new[] { "noise", "planet-height", "camera-ray", "ring-optics", "rock-common", "asteroid-shape", "cloud-common" })
|
||||||
|
{
|
||||||
|
var directive = $"#include \"{include}.glsl\"";
|
||||||
|
if (source.Contains(directive, StringComparison.Ordinal))
|
||||||
|
source = source.Replace(directive, name == include ? string.Empty : Load(include), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Universe.App/Graphics/Shaders/asteroid-shape.glsl
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
uniform vec3 uAsteroidShape;
|
||||||
|
uniform vec3 uAsteroidNoise;
|
||||||
|
uniform vec3 uAsteroidCraters;
|
||||||
|
uniform vec3 uAsteroidSurface;
|
||||||
|
vec3 asteroidHash(vec3 p) {
|
||||||
|
// Integer hashing keeps crater positions identical in vertex and fragment stages.
|
||||||
|
uvec3 q=uvec3(ivec3(floor(p*97.0)));
|
||||||
|
q=q*1664525u+1013904223u;
|
||||||
|
q.x+=q.y*q.z;q.y+=q.z*q.x;q.z+=q.x*q.y;
|
||||||
|
q^=q>>16u;
|
||||||
|
q.x+=q.y*q.z;q.y+=q.z*q.x;q.z+=q.x*q.y;
|
||||||
|
return vec3(q)*(1.0/4294967295.0);
|
||||||
|
}
|
||||||
|
vec3 asteroidAxes(float seed) {
|
||||||
|
vec3 h=asteroidHash(vec3(seed*173.0,19.3,47.1));
|
||||||
|
vec3 axes=vec3(1.0)+(h-0.5)*uAsteroidShape.x*2.0;
|
||||||
|
return axes/pow(axes.x*axes.y*axes.z,1.0/3.0);
|
||||||
|
}
|
||||||
|
float asteroidCraters(vec3 direction,float frequency,float seed) {
|
||||||
|
vec3 offset=vec3(seed*31.7,seed*17.3,seed*23.1);
|
||||||
|
vec3 q=direction*frequency+offset;
|
||||||
|
vec3 cell=floor(q);float height=0.0;
|
||||||
|
for(int z=-1;z<=1;z++)for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++) {
|
||||||
|
vec3 lattice=cell+vec3(x,y,z),h=asteroidHash(lattice+seed*41.0);
|
||||||
|
if(h.z>uAsteroidCraters.z)continue;
|
||||||
|
vec3 center=lattice+0.5+(h-0.5)*0.30-offset;
|
||||||
|
if(abs(length(center)-frequency)>.58)continue;
|
||||||
|
float radius=mix(.24,.63,h.x);
|
||||||
|
float r=length(direction-normalize(center))*frequency/radius;
|
||||||
|
if(r>1.22)continue;
|
||||||
|
float bowl=-0.48*(1.0-r*r)*(1.0-smoothstep(.72,1.0,r));
|
||||||
|
float rim=uAsteroidSurface.x*exp(-pow((r-.96)/.095,2.0));
|
||||||
|
height+=(bowl+rim)*radius/frequency;
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
float asteroidHeight(vec3 direction,float seed) {
|
||||||
|
vec3 offset=vec3(seed*31.0,seed*17.0,seed*47.0);
|
||||||
|
vec3 p=direction*uAsteroidShape.y+offset;
|
||||||
|
vec3 warp=vec3(noise3(p),noise3(p+vec3(9.3,4.7,1.1)),noise3(p+vec3(2.9,17.1,7.3)));
|
||||||
|
p+=warp*.45;
|
||||||
|
float sum=0.0,amplitude=.7,weight=0.0;
|
||||||
|
for(int i=0;i<7;i++) {
|
||||||
|
if(i>=int(uAsteroidNoise.x))break;
|
||||||
|
sum+=gradientNoise3(p)*amplitude;
|
||||||
|
weight+=amplitude;
|
||||||
|
p=octaveRotation*p*2.07+vec3(7.3,13.1,5.9);amplitude*=uAsteroidNoise.y;
|
||||||
|
}
|
||||||
|
float ridges=pow(1.0-abs(noise3(direction*11.0+offset)),3.0)-.4;
|
||||||
|
float craters=asteroidCraters(direction,uAsteroidCraters.x,seed);
|
||||||
|
craters+=asteroidCraters(direction,uAsteroidCraters.x*3.1,seed+5.7)*.8;
|
||||||
|
return sum/max(.1,weight)*uRockMaterial.x+ridges*uAsteroidShape.z+craters*uAsteroidCraters.y;
|
||||||
|
}
|
||||||
|
vec3 asteroidPosition(vec3 direction,float seed) {
|
||||||
|
return direction*asteroidAxes(seed)*clamp(1.0+asteroidHeight(direction,seed),.55,1.6);
|
||||||
|
}
|
||||||
|
float asteroidMicroHeight(vec3 d,float seed,float footprint) {
|
||||||
|
vec3 p=d*uAsteroidSurface.z+seed*61.0;
|
||||||
|
float fine=(noise3(p)+noise3(p*2.7)*.32)*(1.0-smoothstep(.35,1.2,footprint*uAsteroidSurface.z));
|
||||||
|
float smallCraters=asteroidCraters(d,uAsteroidCraters.x*9.0,seed+13.1)*uAsteroidCraters.y;
|
||||||
|
return fine*uAsteroidSurface.y+smallCraters;
|
||||||
|
}
|
||||||
84
src/Universe.App/Graphics/Shaders/atmosphere.frag.glsl
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform vec3 uForward;
|
||||||
|
uniform vec3 uRight;
|
||||||
|
uniform vec3 uUp;
|
||||||
|
uniform float uAspect;
|
||||||
|
uniform float uTanHalfFov;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform vec3 uCameraPlanet;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform float uAtmosphereRadius;
|
||||||
|
uniform float uGroundRadius;
|
||||||
|
uniform vec3 uAtmosphereColor;
|
||||||
|
uniform float uDensityScale;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
out vec4 fragColor;
|
||||||
|
const float PI=3.14159265359;
|
||||||
|
vec2 sphere(vec3 origin, vec3 direction, float radius) {
|
||||||
|
float b=dot(origin,direction);
|
||||||
|
float c=dot(origin,origin)-radius*radius;
|
||||||
|
float h=b*b-c;
|
||||||
|
if(h<0.0) return vec2(1e8,-1e8);
|
||||||
|
h=sqrt(h);
|
||||||
|
return vec2(-b-h,-b+h);
|
||||||
|
}
|
||||||
|
vec2 density(vec3 p) {
|
||||||
|
float altitude=max(length(p)-1.0,-0.0003);
|
||||||
|
float thickness=uAtmosphereRadius-1.0;
|
||||||
|
return exp(-altitude/vec2(thickness*0.055,thickness*0.0175));
|
||||||
|
}
|
||||||
|
vec2 sunDepth(vec3 p, vec3 direction) {
|
||||||
|
vec2 ground=sphere(p,direction,uGroundRadius);
|
||||||
|
if(ground.y>ground.x && ground.x>0.0) return vec2(1e5);
|
||||||
|
float distance=sphere(p,direction,uAtmosphereRadius).y;
|
||||||
|
float stepLength=max(0.0,distance)/7.0;
|
||||||
|
vec2 optical=vec2(0.0);
|
||||||
|
for(int i=0;i<7;i++) optical+=density(p+direction*(float(i)+0.5)*stepLength)*stepLength;
|
||||||
|
return optical;
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 scene=texture(uScene,vUv).rgb;
|
||||||
|
vec2 xy=(vUv*2.0-1.0)*uTanHalfFov;
|
||||||
|
vec3 ray=normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
|
||||||
|
vec2 hit=sphere(uCameraPlanet,ray,uAtmosphereRadius);
|
||||||
|
float start=max(hit.x,0.0);
|
||||||
|
float depth=texture(uDepth,vUv).r;
|
||||||
|
float worldDepth=(exp2(depth*log2(uFar+1.0))-1.0)/max(dot(ray,uForward),0.01);
|
||||||
|
float end=min(hit.y,worldDepth/uRadius);
|
||||||
|
if(end<=start) { fragColor=vec4(scene,1.0); return; }
|
||||||
|
vec3 betaR=uAtmosphereColor*110.0*uDensityScale;
|
||||||
|
vec3 betaM=vec3(4.0)*uDensityScale;
|
||||||
|
vec3 betaAbsorption=vec3(2.1,4.2,0.35)*uDensityScale;
|
||||||
|
vec2 optical=vec2(0.0);
|
||||||
|
vec3 scattering=vec3(0.0);
|
||||||
|
float ds=(end-start)/28.0;
|
||||||
|
float jitter=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
|
||||||
|
for(int i=0;i<28;i++) {
|
||||||
|
vec3 p=uCameraPlanet+ray*(start+(float(i)+0.35+jitter*0.3)*ds);
|
||||||
|
vec2 localDensity=density(p);
|
||||||
|
vec2 stepDepth=localDensity*ds;
|
||||||
|
optical+=stepDepth*0.5;
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount) break;
|
||||||
|
vec3 ld=uLightDirections[l];
|
||||||
|
vec2 lightOptical=sunDepth(p,ld);
|
||||||
|
vec2 total=optical+lightOptical;
|
||||||
|
vec3 transmittance=exp(-min(betaR*total.x+betaM*total.y*1.1+betaAbsorption*total.x,vec3(70.0)));
|
||||||
|
float mu=dot(ray,ld);
|
||||||
|
float rayleighPhase=3.0/(16.0*PI)*(1.0+mu*mu);
|
||||||
|
float g=0.78;
|
||||||
|
float miePhase=3.0/(8.0*PI)*((1.0-g*g)*(1.0+mu*mu))/((2.0+g*g)*pow(max(0.02,1.0+g*g-2.0*g*mu),1.5));
|
||||||
|
scattering+=transmittance*(betaR*stepDepth.x*rayleighPhase+betaM*stepDepth.y*miePhase)
|
||||||
|
*mix(vec3(1.0),uLightColors[l],0.4)*uLightFlux[l]*6.5;
|
||||||
|
}
|
||||||
|
optical+=stepDepth*0.5;
|
||||||
|
}
|
||||||
|
vec3 extinction=exp(-min(betaR*optical.x+betaM*optical.y*1.1+betaAbsorption*optical.x,vec3(70.0)));
|
||||||
|
fragColor=vec4(scene*extinction+scattering,1.0);
|
||||||
|
}
|
||||||
13
src/Universe.App/Graphics/Shaders/blur.frag.glsl
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform vec3 uDirection;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec3 color=texture(uScene,vUv).rgb*0.227027;
|
||||||
|
color+=texture(uScene,vUv+uDirection.xy*1.384615).rgb*0.316216;
|
||||||
|
color+=texture(uScene,vUv-uDirection.xy*1.384615).rgb*0.316216;
|
||||||
|
color+=texture(uScene,vUv+uDirection.xy*3.230769).rgb*0.070270;
|
||||||
|
color+=texture(uScene,vUv-uDirection.xy*3.230769).rgb*0.070270;
|
||||||
|
fragColor=vec4(color,1.0);
|
||||||
|
}
|
||||||
17
src/Universe.App/Graphics/Shaders/bright.frag.glsl
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform vec3 uTexel;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec3 color=vec3(0.0);
|
||||||
|
for(int x=-1;x<=1;x++) for(int y=-1;y<=1;y++) {
|
||||||
|
vec3 c=texture(uScene,vUv+vec2(x,y)*uTexel.xy).rgb;
|
||||||
|
float brightness=max(c.r,max(c.g,c.b));
|
||||||
|
float soft=clamp(brightness-0.65,0.0,0.7);
|
||||||
|
soft=soft*soft/(2.8+0.0001);
|
||||||
|
float contribution=max(brightness-1.0,soft)/max(brightness,0.0001);
|
||||||
|
color+=c*contribution;
|
||||||
|
}
|
||||||
|
fragColor=vec4(color/9.0,1.0);
|
||||||
|
}
|
||||||
9
src/Universe.App/Graphics/Shaders/camera-ray.glsl
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
uniform vec3 uForward;
|
||||||
|
uniform vec3 uRight;
|
||||||
|
uniform vec3 uUp;
|
||||||
|
uniform float uAspect;
|
||||||
|
uniform float uTanHalfFov;
|
||||||
|
vec3 cameraRay(vec2 uv) {
|
||||||
|
vec2 xy=(uv*2.0-1.0)*uTanHalfFov;
|
||||||
|
return normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
|
||||||
|
}
|
||||||
114
src/Universe.App/Graphics/Shaders/cloud-common.glsl
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
uniform sampler3D uCloudShape;
|
||||||
|
uniform sampler3D uCloudDetail;
|
||||||
|
uniform sampler2D uCloudDensityMap;
|
||||||
|
uniform int uCloudEnabled;
|
||||||
|
uniform int uCloudShadowEnabled;
|
||||||
|
uniform int uCloudHasDensityMap;
|
||||||
|
uniform float uCloudDensityTextureStrength;
|
||||||
|
uniform float uCloudPlanetRadius;
|
||||||
|
uniform vec3 uCloudCamera;
|
||||||
|
uniform vec3 uCloudAxisX;
|
||||||
|
uniform vec3 uCloudAxisY;
|
||||||
|
uniform vec3 uCloudAxisZ;
|
||||||
|
uniform mat4 uCloudWorldToBody;
|
||||||
|
uniform vec3 uCloudOrigins[3];
|
||||||
|
uniform vec3 uCloudLayer; // base altitude, thickness, shape scale
|
||||||
|
uniform vec3 uCloudShapeSettings; // coverage, erosion, type
|
||||||
|
uniform vec3 uCloudOptics; // density, extinction/m, shadow strength
|
||||||
|
uniform float uCloudDetailScale;
|
||||||
|
uniform float uCloudWeatherScale;
|
||||||
|
uniform float uCloudPixelAngle;
|
||||||
|
uniform vec3 uCloudAir; // atmosphere height, density, sunset strength
|
||||||
|
uniform vec3 uCloudAirColor;
|
||||||
|
uniform vec3 uCloudPrimarySun;
|
||||||
|
uniform vec3 uCloudPrimaryColor;
|
||||||
|
|
||||||
|
vec3 cloudToLocal(vec3 v) { return vec3(dot(v,uCloudAxisX),dot(v,uCloudAxisY),dot(v,uCloudAxisZ)); }
|
||||||
|
vec3 cloudToWorld(vec3 v) { return uCloudAxisX*v.x+uCloudAxisY*v.y+uCloudAxisZ*v.z; }
|
||||||
|
float cloudAltitude(vec3 p) {
|
||||||
|
return (dot(p,p)+2.0*uCloudPlanetRadius*p.y)/(length(p+vec3(0,uCloudPlanetRadius,0))+uCloudPlanetRadius);
|
||||||
|
}
|
||||||
|
vec2 cloudSphere(vec3 ro,vec3 rd,float altitude) {
|
||||||
|
float b=dot(ro+vec3(0,uCloudPlanetRadius,0),rd);
|
||||||
|
float c=dot(ro,ro)+2.0*uCloudPlanetRadius*(ro.y-altitude)-altitude*altitude;
|
||||||
|
float h=b*b-c;
|
||||||
|
if(h<0.0)return vec2(1e25,-1e25);
|
||||||
|
float q=-b-(b<0.0?-1.0:1.0)*sqrt(h);
|
||||||
|
float d=c/(abs(q)<1e-9?1e-9:q);
|
||||||
|
return vec2(min(q,d),max(q,d));
|
||||||
|
}
|
||||||
|
float cloudDensity(vec3 p,float lod,bool fine) {
|
||||||
|
float height=(cloudAltitude(p)-uCloudLayer.x)/uCloudLayer.y;
|
||||||
|
if(height<=0.0 || height>=1.0)return 0.0;
|
||||||
|
vec3 body=mat3(uCloudWorldToBody)*cloudToWorld(p);
|
||||||
|
float weather=textureLod(uCloudShape,body/uCloudWeatherScale+uCloudOrigins[2],0.0).a;
|
||||||
|
float weatherCover=smoothstep(.30,.62,weather);
|
||||||
|
float coverage=clamp(uCloudShapeSettings.x+(weatherCover-.5)*.55,0.0,1.0);
|
||||||
|
if(coverage<.005)return 0.0;
|
||||||
|
vec3 structure=textureLod(uCloudShape,body/uCloudLayer.z+uCloudOrigins[0],lod).rgb;
|
||||||
|
float shape=structure.r+(dot(structure.gb,vec2(.7,.3))-.5)*.28;
|
||||||
|
float profile=smoothstep(0.0,.09,height)*(1.0-smoothstep(mix(.28,.62,uCloudShapeSettings.z),mix(.52,1.0,uCloudShapeSettings.z),height));
|
||||||
|
float density=clamp((shape-(1.0-coverage))/max(.08,coverage)*3.1,0.0,1.0);
|
||||||
|
if(fine && density>.001) {
|
||||||
|
vec3 erosion=textureLod(uCloudDetail,body/uCloudDetailScale+uCloudOrigins[1],max(0.0,lod-1.0)).rgb;
|
||||||
|
float detail=dot(erosion,vec3(.6,.3,.1));
|
||||||
|
density=clamp(density-(1.0-detail)*uCloudShapeSettings.y*(.45+.55*(1.0-density))*(1.0-smoothstep(1.0,3.0,lod)),0.0,1.0);
|
||||||
|
}
|
||||||
|
// From orbit a pixel covers many individual puffs. Integrate their weather-scale coverage.
|
||||||
|
float distant=smoothstep(.28,.65,coverage)*.5;
|
||||||
|
density=mix(density,distant,smoothstep(2.5,5.5,lod));
|
||||||
|
if(uCloudHasDensityMap!=0) {
|
||||||
|
vec3 radialBody=normalize(mat3(uCloudWorldToBody)*cloudToWorld(p+vec3(0.0,uCloudPlanetRadius,0.0)));
|
||||||
|
vec2 mapUv=vec2(.5-atan(radialBody.z,radialBody.x)/6.28318530718,
|
||||||
|
atan(length(radialBody.xz),radialBody.y)/3.14159265359);
|
||||||
|
float mapWidth=float(textureSize(uCloudDensityMap,0).x);
|
||||||
|
float footprint=max(1.0,length(p)*uCloudPixelAngle*mapWidth/(6.28318530718*uCloudPlanetRadius));
|
||||||
|
float mapLod=clamp(log2(footprint),0.0,log2(max(1.0,mapWidth)));
|
||||||
|
float mask=textureLod(uCloudDensityMap,mapUv,mapLod).r;
|
||||||
|
float coverageMask=smoothstep(.055,.48,mask);
|
||||||
|
density*=mix(1.0,coverageMask,clamp(uCloudDensityTextureStrength,0.0,1.0));
|
||||||
|
}
|
||||||
|
return density*profile*uCloudOptics.x;
|
||||||
|
}
|
||||||
|
vec3 cloudSunTint(vec3 p,vec3 light) {
|
||||||
|
vec3 up=normalize(p+vec3(0,uCloudPlanetRadius,0));
|
||||||
|
float mu=dot(up,light);
|
||||||
|
float scaleHeight=max(100.0,uCloudAir.x*.055);
|
||||||
|
float mass=exp(-max(0.0,cloudAltitude(p))/scaleHeight)/max(.025,mu+sqrt(mu*mu+2.0*scaleHeight/uCloudPlanetRadius));
|
||||||
|
vec3 beta=vec3(.022,.055,.14)*(vec3(.7)+uCloudAirColor*.3);
|
||||||
|
return exp(-beta*mass*uCloudAir.y*uCloudAir.z);
|
||||||
|
}
|
||||||
|
float cloudShadow(vec3 worldPoint,vec3 worldLight) {
|
||||||
|
if(uCloudEnabled==0 || uCloudShadowEnabled==0 || uCloudOptics.z<=0.0)return 1.0;
|
||||||
|
vec3 ro=uCloudCamera+cloudToLocal(worldPoint),rd=cloudToLocal(worldLight);
|
||||||
|
if(cloudAltitude(ro)>uCloudLayer.x+uCloudLayer.y)return 1.0;
|
||||||
|
vec2 outer=cloudSphere(ro,rd,uCloudLayer.x+uCloudLayer.y),inner=cloudSphere(ro,rd,uCloudLayer.x);
|
||||||
|
float start=max(0.0,outer.x),end=min(outer.y,start+150000.0);
|
||||||
|
if(inner.x<0.0 && inner.y>0.0)start=max(start,inner.y);
|
||||||
|
else if(inner.x>start)end=min(end,inner.x);
|
||||||
|
if(end<=start)return 1.0;
|
||||||
|
float ds=(end-start)/6.0,optical=0.0;
|
||||||
|
float lod=clamp(log2(max(1.0,length(worldPoint)*uCloudPixelAngle*128.0/uCloudLayer.z)),1.2,7.0);
|
||||||
|
for(int i=0;i<6;i++)optical+=cloudDensity(ro+rd*(start+(float(i)+.5)*ds),lod,false)*ds;
|
||||||
|
return mix(1.0,exp(-min(20.0,optical*uCloudOptics.y)),uCloudOptics.z);
|
||||||
|
}
|
||||||
|
vec3 cloudReflection(vec3 worldPoint,vec3 worldDirection,vec3 sky) {
|
||||||
|
if(uCloudEnabled==0)return sky;
|
||||||
|
vec3 ro=uCloudCamera+cloudToLocal(worldPoint),rd=cloudToLocal(worldDirection);
|
||||||
|
vec2 outer=cloudSphere(ro,rd,uCloudLayer.x+uCloudLayer.y),inner=cloudSphere(ro,rd,uCloudLayer.x);
|
||||||
|
float start=max(0.0,outer.x),end=min(outer.y,start+100000.0);
|
||||||
|
if(inner.x<0.0 && inner.y>0.0)start=max(start,inner.y);
|
||||||
|
else if(inner.x>start)end=min(end,inner.x);
|
||||||
|
if(end<=start)return sky;
|
||||||
|
float ds=(end-start)/10.0,transmission=1.0;
|
||||||
|
vec3 light=vec3(0.0);
|
||||||
|
for(int i=0;i<10;i++) {
|
||||||
|
vec3 p=ro+rd*(start+(float(i)+.5)*ds);
|
||||||
|
float lod=clamp(log2(max(1.0,(length(worldPoint)+start+(float(i)+.5)*ds)*uCloudPixelAngle*128.0/uCloudLayer.z)),1.0,7.0);
|
||||||
|
float a=1.0-exp(-cloudDensity(p,lod,false)*ds*uCloudOptics.y);
|
||||||
|
float day=smoothstep(-.05,.12,dot(normalize(p+vec3(0,uCloudPlanetRadius,0)),uCloudPrimarySun));
|
||||||
|
vec3 color=(vec3(.15,.2,.3)*.2+uCloudPrimaryColor*cloudSunTint(p,uCloudPrimarySun)*.8)*day+vec3(.001);
|
||||||
|
light+=color*a*transmission;transmission*=1.0-a;
|
||||||
|
}
|
||||||
|
return sky*transmission+light;
|
||||||
|
}
|
||||||
31
src/Universe.App/Graphics/Shaders/cloud-composite.frag.glsl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uClouds;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform vec3 uCloudTexel;
|
||||||
|
out vec4 fragColor;
|
||||||
|
float depthAt(vec2 uv) {
|
||||||
|
ivec2 size=textureSize(uDepth,0);
|
||||||
|
vec2 pixel=uv*vec2(size)-.5,f=fract(pixel);ivec2 base=ivec2(floor(pixel));
|
||||||
|
float value=0.0;
|
||||||
|
for(int y=0;y<2;y++)for(int x=0;x<2;x++)
|
||||||
|
value+=texelFetch(uDepth,clamp(base+ivec2(x,y),ivec2(0),size-1),0).r
|
||||||
|
*(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec2 pixel=vUv/uCloudTexel.xy-.5,base=floor(pixel),blend=fract(pixel);
|
||||||
|
float centerDepth=texture(uDepth,vUv).r;
|
||||||
|
vec4 cloud=vec4(0);float total=0.0;
|
||||||
|
for(int y=0;y<2;y++)for(int x=0;x<2;x++) {
|
||||||
|
vec2 uv=(base+vec2(x,y)+.5)*uCloudTexel.xy;
|
||||||
|
float bilinear=(x==0?1.0-blend.x:blend.x)*(y==0?1.0-blend.y:blend.y);
|
||||||
|
float depth=depthAt(uv);
|
||||||
|
float weight=bilinear*exp(-abs(depth-centerDepth)*2200.0)+.00001;
|
||||||
|
cloud+=texture(uClouds,uv)*weight;total+=weight;
|
||||||
|
}
|
||||||
|
cloud/=total;
|
||||||
|
vec4 scene=texture(uScene,vUv);
|
||||||
|
fragColor=vec4(scene.rgb*cloud.a+cloud.rgb,scene.a*cloud.a);
|
||||||
|
}
|
||||||
124
src/Universe.App/Graphics/Shaders/cloud.frag.glsl
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "camera-ray.glsl"
|
||||||
|
#include "cloud-common.glsl"
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform int uCloudViewSteps;
|
||||||
|
uniform int uCloudLightSteps;
|
||||||
|
uniform float uCloudMaxDistance;
|
||||||
|
uniform vec3 uCloudLighting; // ambient, sunlight, single scattering albedo
|
||||||
|
uniform vec3 uCloudPhase; // anisotropy, silver lining, unused
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
out vec4 fragColor;
|
||||||
|
float opaqueClipDistance(vec2 uv) {
|
||||||
|
// Half-resolution rays land between opaque pixel centres. Interpolate inverse eye depth,
|
||||||
|
// which is linear across a projected triangle, instead of borrowing a neighbour's distance.
|
||||||
|
ivec2 size=textureSize(uDepth,0);
|
||||||
|
vec2 pixel=uv*vec2(size)-.5,f=fract(pixel);
|
||||||
|
ivec2 base=ivec2(floor(pixel));
|
||||||
|
float inverseDepth=0.0;
|
||||||
|
for(int y=0;y<2;y++)for(int x=0;x<2;x++) {
|
||||||
|
float weight=(x==0?1.0-f.x:f.x)*(y==0?1.0-f.y:f.y);
|
||||||
|
float encoded=texelFetch(uDepth,clamp(base+ivec2(x,y),ivec2(0),size-1),0).r;
|
||||||
|
float distance=exp2(encoded*log2(uFar+1.0))-1.0;
|
||||||
|
inverseDepth+=weight/max(.001,distance);
|
||||||
|
}
|
||||||
|
return 1.0/max(1e-20,inverseDepth);
|
||||||
|
}
|
||||||
|
float phaseHG(float mu,float g) { return (1.0-g*g)/pow(max(.01,1.0+g*g-2.0*g*mu),1.5); }
|
||||||
|
float lightTransmission(vec3 p,vec3 light,float lod) {
|
||||||
|
vec2 ground=cloudSphere(p,light,0.0);
|
||||||
|
if(ground.y>ground.x && ground.x>1.0)return 0.0;
|
||||||
|
float distance=min(max(0.0,cloudSphere(p,light,uCloudLayer.x+uCloudLayer.y).y),uCloudLayer.y*12.0);
|
||||||
|
float optical=0.0;
|
||||||
|
for(int i=0;i<16;i++) {
|
||||||
|
if(i>=uCloudLightSteps)break;
|
||||||
|
float a=float(i)/float(uCloudLightSteps),b=(float(i)+1.0)/float(uCloudLightSteps);
|
||||||
|
float sampleT=distance*pow((a+b)*.5,2.0);
|
||||||
|
optical+=cloudDensity(p+light*sampleT,max(.5,lod),false)*distance*(b*b-a*a);
|
||||||
|
}
|
||||||
|
return exp(-min(25.0,optical*uCloudOptics.y));
|
||||||
|
}
|
||||||
|
vec3 sampleLighting(vec3 p,vec3 ray,float density,float lod) {
|
||||||
|
vec3 up=normalize(p+vec3(0,uCloudPlanetRadius,0));
|
||||||
|
float height=clamp((cloudAltitude(p)-uCloudLayer.x)/uCloudLayer.y,0.0,1.0);
|
||||||
|
vec3 light=vec3(.001,.0015,.0025);
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount)break;
|
||||||
|
vec3 ld=uLightDirections[l];
|
||||||
|
float sunHeight=dot(up,ld),day=smoothstep(-.075,.10,sunHeight);
|
||||||
|
vec3 tint=uLightColors[l]*cloudSunTint(p,ld);
|
||||||
|
float transmission=lightTransmission(p,ld,lod);
|
||||||
|
float mu=dot(ray,ld);
|
||||||
|
float phase=.8*phaseHG(mu,uCloudPhase.x)+.2*phaseHG(mu,-.22);
|
||||||
|
float single=transmission*(.14*phase+.15);
|
||||||
|
float multiple=pow(transmission,.32)*.38;
|
||||||
|
float edge=pow(max(0.0,mu),16.0)*sqrt(transmission)*uCloudPhase.y*.5;
|
||||||
|
light+=tint*uLightFlux[l]*day*(single+multiple+edge)*uCloudLighting.y;
|
||||||
|
vec3 sky=mix(vec3(.18,.23,.33),vec3(.60,.70,.84),height);
|
||||||
|
// Multiple scattering inherits the horizon's reddened illumination, including cloud undersides.
|
||||||
|
vec3 ambientTint=tint/max(.001,max(tint.r,max(tint.g,tint.b)));
|
||||||
|
sky*=mix(vec3(1.0),ambientTint,.85);
|
||||||
|
sky*=1.0+(1.0-clamp(ambientTint.b/max(.001,ambientTint.r),0.0,1.0))*1.5;
|
||||||
|
light+=sky*uCloudLighting.x*day*sqrt(uLightFlux[l])*(.65+.35*exp(-density*1.8));
|
||||||
|
}
|
||||||
|
return light*uCloudLighting.z;
|
||||||
|
}
|
||||||
|
void marchRange(float start,float end,vec3 ray,inout vec3 radiance,inout float transmission,inout float weightedDistance) {
|
||||||
|
if(end<=start || transmission<.01)return;
|
||||||
|
end=min(end,start+uCloudMaxDistance);
|
||||||
|
float count=clamp(ceil((end-start)/max(40.0,uCloudLayer.y/40.0)),16.0,float(uCloudViewSteps));
|
||||||
|
float ds=(end-start)/count;
|
||||||
|
float lod=clamp(log2(max(1.0,ds*128.0/uCloudLayer.z)),0.0,3.0);
|
||||||
|
// Static interleaving avoids frame-to-frame noise; the bilateral pass removes low-resolution edges.
|
||||||
|
float jitter=fract(52.9829189*fract(dot(floor(gl_FragCoord.xy),vec2(.06711056,.00583715))));
|
||||||
|
for(int i=0;i<192;i++) {
|
||||||
|
if(float(i)>=count || transmission<.01)break;
|
||||||
|
float t=start+(float(i)+.15+jitter*.7)*ds;
|
||||||
|
vec3 p=uCloudCamera+ray*t;
|
||||||
|
float pixelLod=clamp(log2(max(1.0,t*uCloudPixelAngle*128.0/uCloudLayer.z)),0.0,7.0);
|
||||||
|
float density=cloudDensity(p,max(lod,pixelLod),true);
|
||||||
|
if(density<.001)continue;
|
||||||
|
float alpha=1.0-exp(-density*ds*uCloudOptics.y);
|
||||||
|
float weight=alpha*transmission;
|
||||||
|
radiance+=sampleLighting(p,ray,density,max(lod,pixelLod))*weight;
|
||||||
|
weightedDistance+=t*weight;
|
||||||
|
transmission*=1.0-alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
fragColor=vec4(0,0,0,1);
|
||||||
|
vec3 worldRay=cameraRay(vUv),ray=cloudToLocal(worldRay);
|
||||||
|
vec2 outer=cloudSphere(uCloudCamera,ray,uCloudLayer.x+uCloudLayer.y);
|
||||||
|
float opaque=opaqueClipDistance(vUv)/max(.001,dot(worldRay,uForward));
|
||||||
|
float start=max(0.0,outer.x),end=min(opaque,outer.y);
|
||||||
|
if(end<=start)return;
|
||||||
|
vec2 inner=cloudSphere(uCloudCamera,ray,uCloudLayer.x);
|
||||||
|
vec3 radiance=vec3(0);float transmission=1.0,weightedDistance=0.0;
|
||||||
|
if(inner.y>start && inner.x<end) {
|
||||||
|
marchRange(start,min(end,inner.x),ray,radiance,transmission,weightedDistance);
|
||||||
|
marchRange(max(start,inner.y),end,ray,radiance,transmission,weightedDistance);
|
||||||
|
} else marchRange(start,end,ray,radiance,transmission,weightedDistance);
|
||||||
|
float alpha=1.0-transmission;
|
||||||
|
if(alpha>.0001) {
|
||||||
|
float distance=weightedDistance/alpha;
|
||||||
|
vec2 air=cloudSphere(uCloudCamera,ray,uCloudAir.x);
|
||||||
|
float airStart=max(0.0,air.x),ds=max(0.0,distance-airStart)/6.0,column=0.0;
|
||||||
|
for(int i=0;i<6;i++) {
|
||||||
|
float altitude=cloudAltitude(uCloudCamera+ray*(airStart+(float(i)+.5)*ds));
|
||||||
|
column+=exp(-max(0.0,altitude)/max(100.0,uCloudAir.x*.055))*ds;
|
||||||
|
}
|
||||||
|
vec3 extinction=exp(-(vec3(.000004)+uCloudAirColor*.000012)*column*uCloudAir.y);
|
||||||
|
vec3 p=uCloudCamera+ray*distance;
|
||||||
|
vec3 tint=cloudSunTint(p,uCloudPrimarySun);
|
||||||
|
float warm=1.0-clamp(tint.b/max(.001,tint.r),0.0,1.0);
|
||||||
|
float day=smoothstep(-.08,.10,dot(normalize(p+vec3(0,uCloudPlanetRadius,0)),uCloudPrimarySun));
|
||||||
|
vec3 haze=mix(vec3(.45,.63,.87),vec3(.85,.28,.10),warm)*day*.75;
|
||||||
|
radiance=radiance*extinction+haze*(1.0-extinction)*alpha;
|
||||||
|
}
|
||||||
|
fragColor=vec4(radiance,transmission);
|
||||||
|
}
|
||||||
27
src/Universe.App/Graphics/Shaders/final.frag.glsl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uBloom;
|
||||||
|
uniform vec3 uTexel;
|
||||||
|
uniform float uExposure;
|
||||||
|
out vec4 fragColor;
|
||||||
|
vec3 tone(vec2 uv) {
|
||||||
|
vec3 c=(texture(uScene,uv).rgb+texture(uBloom,uv).rgb*0.16)*uExposure;
|
||||||
|
return clamp((c*(2.51*c+0.03))/(c*(2.43*c+0.59)+0.14),0.0,1.0);
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 c=tone(vUv);
|
||||||
|
vec3 n=tone(vUv+vec2(0,uTexel.y)), s=tone(vUv-vec2(0,uTexel.y));
|
||||||
|
vec3 e=tone(vUv+vec2(uTexel.x,0)), w=tone(vUv-vec2(uTexel.x,0));
|
||||||
|
vec3 luma=vec3(0.299,0.587,0.114);
|
||||||
|
float l=dot(c,luma), lo=min(l,min(min(dot(n,luma),dot(s,luma)),min(dot(e,luma),dot(w,luma))));
|
||||||
|
float hi=max(l,max(max(dot(n,luma),dot(s,luma)),max(dot(e,luma),dot(w,luma))));
|
||||||
|
if(hi-lo>max(0.045,hi*0.18)) {
|
||||||
|
vec2 dir=vec2(dot(s-n,luma),dot(e-w,luma));
|
||||||
|
dir=normalize(dir+vec2(0.000001))*uTexel.xy;
|
||||||
|
c=mix(c,(tone(vUv+dir*0.45)+tone(vUv-dir*0.45))*0.5,0.62);
|
||||||
|
}
|
||||||
|
c=pow(c,vec3(1.0/2.2));
|
||||||
|
float dither=(fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453)-0.5)/255.0;
|
||||||
|
fragColor=vec4(c+dither,1.0);
|
||||||
|
}
|
||||||
7
src/Universe.App/Graphics/Shaders/fullscreen.vert.glsl
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=0) in vec3 aPosition;
|
||||||
|
out vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv=aPosition.xy*0.5+0.5;
|
||||||
|
gl_Position=vec4(aPosition.xy,0.0,1.0);
|
||||||
|
}
|
||||||
143
src/Universe.App/Graphics/Shaders/gas.frag.glsl
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
#include "camera-ray.glsl"
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform vec3 uCameraBody;
|
||||||
|
uniform mat4 uWorldToBody;
|
||||||
|
uniform vec3 uSurfaceColor;
|
||||||
|
uniform vec3 uBandColor;
|
||||||
|
uniform vec3 uPolarColor;
|
||||||
|
uniform vec3 uHazeColor;
|
||||||
|
uniform vec3 uSeedOffset;
|
||||||
|
uniform vec3 uGasShape;
|
||||||
|
uniform vec3 uGasOptics;
|
||||||
|
uniform vec3 uGasLighting;
|
||||||
|
uniform vec3 uGasBands;
|
||||||
|
uniform vec3 uGasDetail;
|
||||||
|
uniform vec3 uGasQuality;
|
||||||
|
uniform float uLightExtinction;
|
||||||
|
uniform float uStopTransmittance;
|
||||||
|
uniform float uWindSpeed;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uBandFrequency;
|
||||||
|
uniform float uStormStrength;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
uniform int uPlanetIndex;
|
||||||
|
uniform int uOccluderCount;
|
||||||
|
uniform vec3 uOccluderCenters[16];
|
||||||
|
uniform float uOccluderRadii[16];
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
vec3 axes() { return vec3(1.0,uGasShape.y,1.0); }
|
||||||
|
vec2 intersectGas(vec3 o,vec3 d) {
|
||||||
|
vec3 a=o/axes(), b=d/axes();
|
||||||
|
float aa=dot(b,b), middle=-dot(a,b)/aa;
|
||||||
|
vec3 closest=a+b*middle;
|
||||||
|
float h=uGasShape.x*uGasShape.x-dot(closest,closest);
|
||||||
|
if(h<0.0)return vec2(1e8,-1e8);
|
||||||
|
h=sqrt(h/aa);return vec2(middle-h,middle+h);
|
||||||
|
}
|
||||||
|
float radialDensity(vec3 p) {
|
||||||
|
float r=length(p/axes());
|
||||||
|
float edge=1.0-smoothstep(uGasShape.x-max(0.0001,uGasShape.z*0.25),uGasShape.x,r);
|
||||||
|
return exp(clamp((1.0-r)/uGasShape.z,-12.0,4.0))*edge;
|
||||||
|
}
|
||||||
|
float sunTransmittance(vec3 p,vec3 ld) {
|
||||||
|
float lengthToExit=max(0.0,intersectGas(p,ld).y);
|
||||||
|
float optical=0.0;
|
||||||
|
int count=int(uGasQuality.y);
|
||||||
|
for(int i=0;i<16;i++) {
|
||||||
|
if(i>=count)break;
|
||||||
|
float a=float(i)/float(count), b=float(i+1)/float(count);
|
||||||
|
float ds=lengthToExit*(b*b-a*a);
|
||||||
|
float t=lengthToExit*(a*a+b*b)*0.5;
|
||||||
|
optical+=radialDensity(p+ld*t)*ds*uGasOptics.x;
|
||||||
|
if(optical*uLightExtinction>15.0)break;
|
||||||
|
}
|
||||||
|
return exp(-optical*uLightExtinction);
|
||||||
|
}
|
||||||
|
vec3 cloudColor(vec3 p) {
|
||||||
|
vec3 d=normalize(p/axes());
|
||||||
|
float latitude=atan(d.y,length(d.xz));
|
||||||
|
float longitude=atan(d.z,d.x)-uTime*uWindSpeed;
|
||||||
|
float flow=noise3(vec3(cos(longitude)*8.0,latitude*5.0,sin(longitude)*8.0)+uSeedOffset);
|
||||||
|
float bandPosition=latitude*uBandFrequency+flow*uGasDetail.x;
|
||||||
|
float broad=noise3(vec3(bandPosition,2.7,uSeedOffset.x));
|
||||||
|
float fine=noise3(vec3(latitude*uGasBands.y+flow*0.3,7.1,uSeedOffset.y));
|
||||||
|
float stripe=clamp(0.5+broad*uGasBands.x+fine*uGasBands.z,0.0,1.0);
|
||||||
|
vec3 color=mix(uSurfaceColor,uBandColor,stripe);
|
||||||
|
color*=1.0+noise3(d*uGasDetail.z+uSeedOffset)*uGasDetail.y;
|
||||||
|
float polar=smoothstep(0.62,1.35,abs(latitude));
|
||||||
|
color=mix(color,uPolarColor,polar*0.65);
|
||||||
|
float dx=atan(sin(longitude+0.35),cos(longitude+0.35));
|
||||||
|
vec2 storm=vec2(dx*cos(latitude),latitude+0.36)/vec2(0.20,0.075);
|
||||||
|
float spot=exp(-dot(storm,storm))*uStormStrength;
|
||||||
|
color=mix(color,uBandColor*0.60,spot);
|
||||||
|
float highAltitude=smoothstep(0.995,uGasShape.x,length(p/axes()));
|
||||||
|
return mix(color,uHazeColor,highAltitude*0.18);
|
||||||
|
}
|
||||||
|
float eclipse(vec3 p,vec3 ld) {
|
||||||
|
float visibility=1.0;
|
||||||
|
for(int j=0;j<16;j++) {
|
||||||
|
if(j>=uOccluderCount)break;
|
||||||
|
if(j==uPlanetIndex)continue;
|
||||||
|
vec3 o=uOccluderCenters[j]-p;
|
||||||
|
float t=dot(o,ld);
|
||||||
|
if(t>0.0) {
|
||||||
|
float miss=length(o-ld*t);
|
||||||
|
float soft=max(0.00001,uOccluderRadii[j]*0.02);
|
||||||
|
visibility*=smoothstep(uOccluderRadii[j]-soft,uOccluderRadii[j]+soft,miss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visibility;
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 ray=cameraRay(vUv);
|
||||||
|
vec3 rd=normalize(mat3(uWorldToBody)*ray);
|
||||||
|
vec2 hit=intersectGas(uCameraBody,rd);
|
||||||
|
float sceneDepth=texture(uDepth,vUv).r;
|
||||||
|
float sceneDistance=(exp2(sceneDepth*log2(uFar+1.0))-1.0)/max(dot(ray,uForward),0.001);
|
||||||
|
float start=max(0.0,hit.x), end=min(hit.y,sceneDistance/uRadius);
|
||||||
|
if(end<=start)discard;
|
||||||
|
int count=int(uGasQuality.x);
|
||||||
|
float transmittance=1.0, firstDepth=-1.0;
|
||||||
|
vec3 scatter=vec3(0.0);
|
||||||
|
float jitter=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
|
||||||
|
for(int i=0;i<192;i++) {
|
||||||
|
if(i>=count)break;
|
||||||
|
float a=pow(float(i)/float(count),uGasQuality.z), b=pow(float(i+1)/float(count),uGasQuality.z);
|
||||||
|
float ds=(end-start)*(b-a);
|
||||||
|
float t=start+(end-start)*mix(a,b,0.35+jitter*0.3);
|
||||||
|
vec3 p=uCameraBody+rd*t;
|
||||||
|
float rho=radialDensity(p)*(1.0+uGasDetail.y*noise3(normalize(p)*uGasDetail.z+uSeedOffset));
|
||||||
|
float alpha=1.0-exp(-rho*ds*uGasOptics.x);
|
||||||
|
vec3 illumination=vec3(uGasLighting.y);
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount)break;
|
||||||
|
vec3 ld=uLightDirections[l];
|
||||||
|
float visibility=sunTransmittance(p,ld)*eclipse(p,ld)*ringTransmission(p,ld);
|
||||||
|
float g=uGasOptics.z, mu=dot(rd,ld);
|
||||||
|
float phase=(1.0-g*g)/pow(max(0.05,1.0+g*g-2.0*g*mu),1.5);
|
||||||
|
illumination+=uLightColors[l]*uLightFlux[l]*uGasLighting.x*visibility*mix(1.0,phase,0.35);
|
||||||
|
}
|
||||||
|
scatter+=transmittance*alpha*cloudColor(p)*illumination*uGasOptics.y;
|
||||||
|
transmittance*=1.0-alpha;
|
||||||
|
if(firstDepth<0.0 && transmittance<0.65)firstDepth=t;
|
||||||
|
if(transmittance<uStopTransmittance)break;
|
||||||
|
}
|
||||||
|
if(transmittance>0.9995)discard;
|
||||||
|
fragColor=vec4(texture(uScene,vUv).rgb*transmittance+scatter,1.0);
|
||||||
|
gl_FragDepth=sceneDepth;
|
||||||
|
if(firstDepth>=0.0) {
|
||||||
|
float clipDistance=firstDepth*uRadius*max(dot(ray,uForward),0.001);
|
||||||
|
gl_FragDepth=min(sceneDepth,log2(1.0+clipDistance)/log2(1.0+uFar));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Universe.App/Graphics/Shaders/noise.glsl
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
uniform sampler3D uNoise;
|
||||||
|
const mat3 octaveRotation = mat3(0.0,-0.8,-0.6, 0.8,0.36,-0.48, 0.6,-0.48,0.64);
|
||||||
|
float noise3(vec3 p) {
|
||||||
|
vec3 i = floor(p);
|
||||||
|
vec3 f = fract(p);
|
||||||
|
f = f*f*f*(f*(f*6.0-15.0)+10.0);
|
||||||
|
return texture(uNoise, (i+f+0.5)/64.0).a;
|
||||||
|
}
|
||||||
|
float preciseNoise3(vec3 p) {
|
||||||
|
ivec3 i=ivec3(floor(p));
|
||||||
|
vec3 f=fract(p);
|
||||||
|
f=f*f*f*(f*(f*6.0-15.0)+10.0);
|
||||||
|
float a=mix(texelFetch(uNoise,i&63,0).a,texelFetch(uNoise,(i+ivec3(1,0,0))&63,0).a,f.x);
|
||||||
|
float b=mix(texelFetch(uNoise,(i+ivec3(0,1,0))&63,0).a,texelFetch(uNoise,(i+ivec3(1,1,0))&63,0).a,f.x);
|
||||||
|
float c=mix(texelFetch(uNoise,(i+ivec3(0,0,1))&63,0).a,texelFetch(uNoise,(i+ivec3(1,0,1))&63,0).a,f.x);
|
||||||
|
float d=mix(texelFetch(uNoise,(i+ivec3(0,1,1))&63,0).a,texelFetch(uNoise,(i+ivec3(1,1,1))&63,0).a,f.x);
|
||||||
|
return mix(mix(a,b,f.y),mix(c,d,f.y),f.z);
|
||||||
|
}
|
||||||
|
float gradientNoise3(vec3 p) {
|
||||||
|
ivec3 i=ivec3(floor(p));
|
||||||
|
vec3 f=fract(p), t=f*f*f*(f*(f*6.0-15.0)+10.0);
|
||||||
|
float a=mix(dot(texelFetch(uNoise,i&63,0).rgb,f),dot(texelFetch(uNoise,(i+ivec3(1,0,0))&63,0).rgb,f-vec3(1,0,0)),t.x);
|
||||||
|
float b=mix(dot(texelFetch(uNoise,(i+ivec3(0,1,0))&63,0).rgb,f-vec3(0,1,0)),dot(texelFetch(uNoise,(i+ivec3(1,1,0))&63,0).rgb,f-vec3(1,1,0)),t.x);
|
||||||
|
float c=mix(dot(texelFetch(uNoise,(i+ivec3(0,0,1))&63,0).rgb,f-vec3(0,0,1)),dot(texelFetch(uNoise,(i+ivec3(1,0,1))&63,0).rgb,f-vec3(1,0,1)),t.x);
|
||||||
|
float d=mix(dot(texelFetch(uNoise,(i+ivec3(0,1,1))&63,0).rgb,f-vec3(0,1,1)),dot(texelFetch(uNoise,(i+ivec3(1,1,1))&63,0).rgb,f-vec3(1,1,1)),t.x);
|
||||||
|
return clamp(mix(mix(a,b,t.y),mix(c,d,t.y),t.z)*1.7,-1.0,1.0);
|
||||||
|
}
|
||||||
|
float fbm(vec3 p) {
|
||||||
|
float result=0.0, amplitude=0.5;
|
||||||
|
for(int i=0;i<5;i++) {
|
||||||
|
result += noise3(p)*amplitude;
|
||||||
|
p = octaveRotation*p*2.03 + vec3(17.1,9.2,13.5);
|
||||||
|
amplitude *= 0.5;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
float terrainHeight(vec3 direction, vec3 offset, int octaves) {
|
||||||
|
vec3 p = direction*3.2 + offset;
|
||||||
|
vec3 warp = vec3(preciseNoise3(p*0.82+vec3(5.2,1.3,9.7)),
|
||||||
|
preciseNoise3(p*0.82+vec3(19.1,7.7,2.4)),
|
||||||
|
preciseNoise3(p*0.82+vec3(3.4,21.8,11.6)));
|
||||||
|
float continent=preciseNoise3(p*0.47);
|
||||||
|
p=direction*52.0+offset+warp*2.6;
|
||||||
|
float weight=1.0, amplitude=0.58, sum=0.0;
|
||||||
|
for(int i=0;i<7;i++) {
|
||||||
|
if(i>=octaves) break;
|
||||||
|
float ridge = 1.0-abs(gradientNoise3(p));
|
||||||
|
ridge = ridge*ridge*ridge*weight;
|
||||||
|
sum += ridge*amplitude;
|
||||||
|
weight=0.55+0.45*ridge;
|
||||||
|
p=octaveRotation*p*2.03 + vec3(17.1,9.2,13.5);
|
||||||
|
amplitude*=0.50;
|
||||||
|
}
|
||||||
|
return (sum-0.42)*0.95+continent*0.16;
|
||||||
|
}
|
||||||
208
src/Universe.App/Graphics/Shaders/ocean.frag.glsl
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
#include "planet-height.glsl"
|
||||||
|
#include "camera-ray.glsl"
|
||||||
|
#include "cloud-common.glsl"
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform float uSeaRadius;
|
||||||
|
uniform float uCameraHeight;
|
||||||
|
uniform vec3 uAxisX;
|
||||||
|
uniform vec3 uAxisY;
|
||||||
|
uniform vec3 uAxisZ;
|
||||||
|
uniform vec3 uWaveVectors[12];
|
||||||
|
uniform float uWavePhases[12];
|
||||||
|
uniform float uWaveAmplitudes[12];
|
||||||
|
uniform vec3 uDetailOrigins[2];
|
||||||
|
uniform vec3 uWaveSettings; // maximum displacement, choppiness, pixel angular size
|
||||||
|
uniform vec3 uWaterOptics; // roughness, reflection, refraction
|
||||||
|
uniform vec3 uDeepColor;
|
||||||
|
uniform vec3 uShallowColor;
|
||||||
|
uniform vec3 uAbsorption;
|
||||||
|
uniform float uFoamStrength;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
uniform float uTime;
|
||||||
|
out vec4 fragColor;
|
||||||
|
const float PI=3.14159265359;
|
||||||
|
vec3 toLocal(vec3 v) { return vec3(dot(v,uAxisX),dot(v,uAxisY),dot(v,uAxisZ)); }
|
||||||
|
vec3 toWorld(vec3 v) { return uAxisX*v.x+uAxisY*v.y+uAxisZ*v.z; }
|
||||||
|
float sceneDistance(vec2 uv,vec3 ray) {
|
||||||
|
return (exp2(texture(uDepth,uv).r*log2(uFar+1.0))-1.0)/max(.001,dot(ray,uForward));
|
||||||
|
}
|
||||||
|
// Stable roots avoid subtracting two planet-sized floats when the camera is metres above water.
|
||||||
|
vec2 oceanSphere(vec3 ro,vec3 rd,float expansion) {
|
||||||
|
float b=dot(ro+vec3(0,uSeaRadius,0),rd);
|
||||||
|
float c=dot(ro,ro)+2.0*uSeaRadius*(ro.y-expansion)-expansion*expansion;
|
||||||
|
float h=b*b-c;
|
||||||
|
if(h<0.0)return vec2(1e25,-1e25);
|
||||||
|
float q=-b-(b<0.0?-1.0:1.0)*sqrt(h);
|
||||||
|
float a=q, d=c/(abs(q)<1e-12?1e-12:q);
|
||||||
|
return vec2(min(a,d),max(a,d));
|
||||||
|
}
|
||||||
|
float radialHeight(vec3 p) {
|
||||||
|
return (dot(p,p)+2.0*uSeaRadius*p.y)/(length(p+vec3(0,uSeaRadius,0))+uSeaRadius);
|
||||||
|
}
|
||||||
|
float waveHeight(vec3 p,float footprint) {
|
||||||
|
float height=0.0;
|
||||||
|
for(int i=0;i<8;i++) {
|
||||||
|
float weight=1.0-smoothstep(.35,1.0,length(uWaveVectors[i])*footprint);
|
||||||
|
float base=dot(p,uWaveVectors[i])+uWavePhases[i];
|
||||||
|
float phase=base;
|
||||||
|
for(int j=0;j<2;j++)phase=base-uWaveSettings.y*.48*cos(phase);
|
||||||
|
height+=sin(phase)*uWaveAmplitudes[i]*weight;
|
||||||
|
}
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
vec3 waveNormal(vec3 p,float footprint,out float compression) {
|
||||||
|
vec3 gradient=vec3(0); compression=0.0;
|
||||||
|
float total=0.0;
|
||||||
|
for(int i=0;i<12;i++) {
|
||||||
|
float weight=1.0-smoothstep(.35,1.0,length(uWaveVectors[i])*footprint);
|
||||||
|
float base=dot(p,uWaveVectors[i])+uWavePhases[i], phase=base;
|
||||||
|
float q=uWaveSettings.y*.48;
|
||||||
|
for(int j=0;j<3;j++)phase=base-q*cos(phase);
|
||||||
|
float a=uWaveAmplitudes[i]*weight;
|
||||||
|
gradient+=uWaveVectors[i]*(a*cos(phase)/max(.15,1.0-q*sin(phase)));
|
||||||
|
compression+=max(0.0,sin(phase))*a;
|
||||||
|
total+=a;
|
||||||
|
}
|
||||||
|
compression/=max(.001,total);
|
||||||
|
vec3 up=normalize(p+vec3(0,uSeaRadius,0));
|
||||||
|
gradient-=up*dot(gradient,up);
|
||||||
|
// Capillary ripples use a planet-anchored noise field and disappear below the pixel footprint.
|
||||||
|
vec3 bodyPoint=mat3(uWorldToBody)*toWorld(p);
|
||||||
|
vec3 bodyX=mat3(uWorldToBody)*uAxisX,bodyZ=mat3(uWorldToBody)*uAxisZ;
|
||||||
|
for(int detail=0;detail<2;detail++) {
|
||||||
|
float frequency=detail==0?.35:1.4;
|
||||||
|
float weight=1.0-smoothstep(.25,.8,footprint*frequency);
|
||||||
|
vec3 q=bodyPoint*frequency+uDetailOrigins[detail];
|
||||||
|
float gx=(noise3(q+bodyX*.04)-noise3(q-bodyX*.04))/.08;
|
||||||
|
float gz=(noise3(q+bodyZ*.04)-noise3(q-bodyZ*.04))/.08;
|
||||||
|
gradient+=vec3(gx,0,gz)*weight*(detail==0?.13:.075);
|
||||||
|
}
|
||||||
|
return normalize(up-gradient);
|
||||||
|
}
|
||||||
|
vec3 skyReflection(vec3 direction,vec3 up) {
|
||||||
|
float daylight=0.0;
|
||||||
|
for(int l=0;l<2;l++){if(l>=uLightCount)break;daylight+=sqrt(uLightFlux[l])*smoothstep(-.08,.16,dot(up,uLightDirections[l]));}
|
||||||
|
float elevation=max(0.0,dot(direction,up));
|
||||||
|
vec3 sky=mix(vec3(.56,.73,.87),vec3(.12,.32,.64),sqrt(elevation));
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount)break;
|
||||||
|
sky+=uLightColors[l]*pow(max(0.0,dot(direction,uLightDirections[l])),24.0)*.22*uLightFlux[l];
|
||||||
|
}
|
||||||
|
return sky*(.006+daylight*1.35);
|
||||||
|
}
|
||||||
|
vec3 screenReflection(vec3 position,vec3 direction,vec3 fallback) {
|
||||||
|
vec3 world=toWorld(position-vec3(0,uCameraHeight,0));
|
||||||
|
vec3 reflected=toWorld(direction);
|
||||||
|
float stepSize=3.0;
|
||||||
|
for(int i=0;i<9;i++) {
|
||||||
|
vec3 q=world+reflected*stepSize;
|
||||||
|
float z=dot(q,uForward);
|
||||||
|
if(z>.1) {
|
||||||
|
vec2 uv=.5+vec2(dot(q,uRight)/(uAspect*uTanHalfFov),dot(q,uUp)/uTanHalfFov)/(2.0*z);
|
||||||
|
if(all(greaterThan(uv,vec2(.015))) && all(lessThan(uv,vec2(.985)))) {
|
||||||
|
float depth=exp2(texture(uDepth,uv).r*log2(uFar+1.0))-1.0;
|
||||||
|
if(z>depth && z-depth<max(1.0,stepSize*.09))return texture(uScene,uv).rgb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stepSize*=1.9;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 scene=texture(uScene,vUv).rgb;
|
||||||
|
float originalDepth=texture(uDepth,vUv).r;
|
||||||
|
gl_FragDepth=originalDepth;
|
||||||
|
vec3 worldRay=cameraRay(vUv), rd=toLocal(worldRay), ro=vec3(0,uCameraHeight,0);
|
||||||
|
float opaque=sceneDistance(vUv,worldRay);
|
||||||
|
float displacement=uWaveSettings.x;
|
||||||
|
bool underwater=uCameraHeight<displacement && uCameraHeight<waveHeight(ro,.02);
|
||||||
|
vec2 envelope=oceanSphere(ro,rd,displacement);
|
||||||
|
vec2 base=oceanSphere(ro,rd,0.0);
|
||||||
|
float t=base.x>0.0?base.x:base.y;
|
||||||
|
bool hit=envelope.y>0.0 && t>0.0;
|
||||||
|
if(hit && abs(uCameraHeight)<2500.0 && t<2000.0) {
|
||||||
|
vec2 inner=oceanSphere(ro,rd,-displacement);
|
||||||
|
float low=max(.015,envelope.x),high=inner.x>0.0?inner.x:envelope.y;
|
||||||
|
if(underwater) {low=max(.015,inner.y);high=envelope.y;}
|
||||||
|
float reference=radialHeight(ro+rd*low)-waveHeight(ro+rd*low,max(.03,low*uWaveSettings.z));
|
||||||
|
float endValue=radialHeight(ro+rd*high)-waveHeight(ro+rd*high,max(.03,high*uWaveSettings.z));
|
||||||
|
if(reference*endValue<0.0 && high>low) {
|
||||||
|
for(int i=0;i<18;i++) {
|
||||||
|
float mid=(low+high)*.5;
|
||||||
|
vec3 p=ro+rd*mid;
|
||||||
|
float height=radialHeight(p)-waveHeight(p,max(.03,mid*uWaveSettings.z));
|
||||||
|
if(height*reference>0.0)low=mid;else high=mid;
|
||||||
|
}
|
||||||
|
float solved=(low+high)*.5;
|
||||||
|
t=mix(solved,t,smoothstep(800.0,2000.0,t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec3 p=ro+rd*max(0.0,t);
|
||||||
|
vec3 radial=normalize(uAxisY+toWorld(p)/uSeaRadius);
|
||||||
|
float waterMask=uHasOceanMask!=0?oceanMaskValue(bodyDirection(radial)):1.0;
|
||||||
|
hit=hit && waterMask>.01 && t<opaque+.15;
|
||||||
|
if(!hit) {
|
||||||
|
float maskHere=uHasOceanMask!=0?oceanMaskValue(bodyDirection(uAxisY)):1.0;
|
||||||
|
if(underwater && maskHere>.5) {
|
||||||
|
vec3 transmission=exp(-uAbsorption*min(opaque,400.0));
|
||||||
|
scene=scene*transmission+uShallowColor*(1.0-transmission)*.5;
|
||||||
|
}
|
||||||
|
fragColor=vec4(scene,1);return;
|
||||||
|
}
|
||||||
|
float footprint=max(.02,t*uWaveSettings.z/max(.12,abs(dot(rd,normalize(p+vec3(0,uSeaRadius,0))))));
|
||||||
|
float compression;
|
||||||
|
vec3 n=waveNormal(p,footprint,compression), up=normalize(p+vec3(0,uSeaRadius,0));
|
||||||
|
vec3 view=-rd;
|
||||||
|
if(dot(n,view)<0.0)n=-n;
|
||||||
|
float ndv=max(.02,dot(n,view));
|
||||||
|
float fresnel=.0204+.9796*pow(1.0-ndv,5.0);
|
||||||
|
vec3 reflectionDirection=reflect(rd,n);
|
||||||
|
vec3 reflected=skyReflection(reflectionDirection,up);
|
||||||
|
reflected=cloudReflection(toWorld(p-ro),toWorld(reflectionDirection),reflected);
|
||||||
|
if(t<1500.0 && !underwater)reflected=screenReflection(p,reflectionDirection,reflected);
|
||||||
|
vec2 refractUv=clamp(vUv+vec2(dot(toWorld(n),uRight),dot(toWorld(n),uUp))*uWaterOptics.z/(1.0+t*.012),vec2(.001),vec2(.999));
|
||||||
|
float floorDistance=sceneDistance(refractUv,cameraRay(refractUv));
|
||||||
|
float thickness=max(.0,min(400.0,floorDistance-t));
|
||||||
|
vec3 transmission=exp(-uAbsorption*thickness);
|
||||||
|
vec3 scatter=mix(uShallowColor,uDeepColor,1.0-exp(-thickness*.075));
|
||||||
|
vec3 lighting=vec3(.04), highlights=vec3(0);
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount)break;
|
||||||
|
vec3 light=uLightDirections[l];
|
||||||
|
float sunHeight=dot(up,light), visible=smoothstep(-.02,.03,sunHeight);
|
||||||
|
visible*=cloudShadow(toWorld(p-ro),toWorld(light));
|
||||||
|
float ndl=max(.001,dot(n,light));
|
||||||
|
lighting+=uLightColors[l]*uLightFlux[l]*visible*(.25+.75*max(0.0,sunHeight));
|
||||||
|
vec3 halfVector=normalize(view+light);
|
||||||
|
float ndh=max(0.0,dot(n,halfVector));
|
||||||
|
float alpha=max(.045,uWaterOptics.x*uWaterOptics.x+min(.14,footprint*.001));
|
||||||
|
float a2=alpha*alpha, den=ndh*ndh*(a2-1.0)+1.0;
|
||||||
|
float distribution=a2/(PI*den*den);
|
||||||
|
float k=(alpha+1.0)*(alpha+1.0)/8.0;
|
||||||
|
float geometry=ndv/(ndv*(1.0-k)+k)*ndl/(ndl*(1.0-k)+k);
|
||||||
|
float f=.0204+.9796*pow(1.0-max(0.0,dot(view,halfVector)),5.0);
|
||||||
|
highlights+=uLightColors[l]*uLightFlux[l]*visible*min(25.0,distribution*geometry*f/(4.0*ndv))*1.8;
|
||||||
|
scatter+=uShallowColor*pow(max(0.0,dot(view,-light)),4.0)*compression*.28*visible;
|
||||||
|
}
|
||||||
|
vec3 refracted=texture(uScene,refractUv).rgb*transmission+scatter*(1.0-transmission)*lighting;
|
||||||
|
vec3 color=mix(refracted,reflected*uWaterOptics.y,fresnel)+highlights;
|
||||||
|
float shore=(1.0-smoothstep(.2,3.5,thickness))*(.5+.5*sin(uTime*1.3+t*.03));
|
||||||
|
vec3 bodyDetail=mat3(uWorldToBody)*toWorld(p)*.35+uDetailOrigins[0];
|
||||||
|
float breakup=noise3(bodyDetail+vec3(uTime*.09,0,uTime*.06))*.5+.5;
|
||||||
|
float foam=clamp((smoothstep(.54,.78,compression)+shore*.7)*uFoamStrength*smoothstep(.22,.72,breakup),0.0,.9);
|
||||||
|
color=mix(color,vec3(.72,.86,.88)*lighting,foam);
|
||||||
|
if(underwater) {
|
||||||
|
vec3 through=exp(-uAbsorption*min(t,400.0));
|
||||||
|
color=color*through+uShallowColor*(1.0-through)*.5;
|
||||||
|
}
|
||||||
|
fragColor=vec4(max(color,vec3(0)),1);
|
||||||
|
gl_FragDepth=clamp(log2(1.0+t*max(.001,dot(worldRay,uForward)))/log2(uFar+1.0),0.0,1.0);
|
||||||
|
}
|
||||||
53
src/Universe.App/Graphics/Shaders/planet-height.glsl
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
uniform sampler2D uHeightMap;
|
||||||
|
uniform int uHasHeightMap;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform float uAmplitude;
|
||||||
|
uniform float uNoiseFrequency;
|
||||||
|
uniform vec3 uNoiseOffset;
|
||||||
|
uniform vec3 uHeightRange;
|
||||||
|
uniform vec3 uLandMask;
|
||||||
|
uniform mat4 uWorldToBody;
|
||||||
|
uniform sampler2D uOceanMask;
|
||||||
|
uniform int uHasOceanMask;
|
||||||
|
uniform vec3 uOceanTerrain; // sea level/radius, depth/radius, enabled
|
||||||
|
uniform sampler2D uRegionalHeight;
|
||||||
|
uniform int uHasRegionalHeight;
|
||||||
|
uniform vec3 uRegionBounds; // west, south, east in degrees
|
||||||
|
uniform vec3 uRegionOther; // north, fade in degrees
|
||||||
|
|
||||||
|
vec3 bodyDirection(vec3 direction) { return normalize(mat3(uWorldToBody)*normalize(direction)); }
|
||||||
|
vec2 planetUv(vec3 d) { return vec2(0.5-atan(d.z,d.x)/6.28318530718,atan(length(d.xz),d.y)/3.14159265359); }
|
||||||
|
float mapTexel(sampler2D raster,ivec2 p, ivec2 size) {
|
||||||
|
// Bilinear sampling can cross only one column outside either longitude edge.
|
||||||
|
int x=p.x<0 ? p.x+size.x : (p.x>=size.x ? p.x-size.x : p.x);
|
||||||
|
return texelFetch(raster,ivec2(x,clamp(p.y,0,size.y-1)),0).r;
|
||||||
|
}
|
||||||
|
float rasterValue(sampler2D raster,vec2 uv) {
|
||||||
|
ivec2 size=textureSize(raster,0);
|
||||||
|
vec2 p=vec2(fract(uv.x)*float(size.x)-0.5,clamp(uv.y*float(size.y)-0.5,0.0,float(size.y-1)));
|
||||||
|
ivec2 i=ivec2(floor(p)); vec2 f=fract(p);
|
||||||
|
float a=mix(mapTexel(raster,i,size),mapTexel(raster,i+ivec2(1,0),size),f.x);
|
||||||
|
float b=mix(mapTexel(raster,i+ivec2(0,1),size),mapTexel(raster,i+ivec2(1,1),size),f.x);
|
||||||
|
return mix(a,b,f.y);
|
||||||
|
}
|
||||||
|
float heightMapValue(vec3 localDirection) { return rasterValue(uHeightMap,planetUv(localDirection)); }
|
||||||
|
float oceanMaskValue(vec3 localDirection) { return rasterValue(uOceanMask,planetUv(localDirection)); }
|
||||||
|
float surfaceElevation(vec3 worldDirection) {
|
||||||
|
vec3 d=bodyDirection(worldDirection);
|
||||||
|
float mapValue=uHasHeightMap!=0 ? heightMapValue(d) : 0.0;
|
||||||
|
float mapped=uHasHeightMap!=0 ? mix(uHeightRange.x,uHeightRange.y,pow(mapValue,uHeightRange.z)) : 0.0;
|
||||||
|
if(uHasRegionalHeight!=0) {
|
||||||
|
// Avoid subtracting nearby global UVs: the regional tile is only one degree wide.
|
||||||
|
float lon=-degrees(atan(d.z,d.x)),lat=degrees(atan(d.y,length(d.xz)));
|
||||||
|
float edge=min(min(lon-uRegionBounds.x,uRegionBounds.z-lon),min(lat-uRegionBounds.y,uRegionOther.x-lat));
|
||||||
|
if(edge>0.0) {
|
||||||
|
vec2 pos=vec2((lon-uRegionBounds.x)/(uRegionBounds.z-uRegionBounds.x),
|
||||||
|
(uRegionOther.x-lat)/(uRegionOther.x-uRegionBounds.y));
|
||||||
|
mapped=mix(mapped,texture(uRegionalHeight,clamp(pos,0.0,1.0)).r/uRadius,smoothstep(0.0,uRegionOther.y,edge));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float mask=(uHasHeightMap!=0 && uLandMask.x>0.5) ? smoothstep(uLandMask.y,uLandMask.y+uLandMask.z,mapValue) : 1.0;
|
||||||
|
float detail=uAmplitude>0.0 ? terrainHeight(d*uNoiseFrequency,uNoiseOffset,9)*uAmplitude*mask : 0.0;
|
||||||
|
float water=uHasOceanMask!=0 ? oceanMaskValue(d) : 0.0;
|
||||||
|
return mix(mapped+detail,uOceanTerrain.x-uOceanTerrain.y,water*uOceanTerrain.z);
|
||||||
|
}
|
||||||
10
src/Universe.App/Graphics/Shaders/point.frag.glsl
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec3 vColor;
|
||||||
|
uniform float uBrightness;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
float r=length(gl_PointCoord*2.0-1.0);
|
||||||
|
if(r>1.0) discard;
|
||||||
|
float core=exp(-r*r*6.0);
|
||||||
|
fragColor=vec4(vColor*core*uBrightness,1.0);
|
||||||
|
}
|
||||||
15
src/Universe.App/Graphics/Shaders/point.vert.glsl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=0) in vec3 aPosition;
|
||||||
|
layout(location=1) in vec3 aColor;
|
||||||
|
layout(location=2) in float aSize;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform float uScale;
|
||||||
|
out vec3 vColor;
|
||||||
|
void main() {
|
||||||
|
vec4 clip=uViewProjection*vec4(aPosition*uScale,1.0);
|
||||||
|
gl_Position=clip;
|
||||||
|
gl_Position.z=(2.0*log2(max(0.0001,clip.w+1.0))/log2(uFar+1.0)-1.0)*clip.w;
|
||||||
|
gl_PointSize=aSize;
|
||||||
|
vColor=aColor;
|
||||||
|
}
|
||||||
17
src/Universe.App/Graphics/Shaders/ring-optics.glsl
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
uniform sampler2D uRingProfile;
|
||||||
|
uniform int uHasRings;
|
||||||
|
uniform vec3 uRingBounds;
|
||||||
|
uniform float uRingShadowSoftness;
|
||||||
|
vec4 ringMaterial(float radius) {
|
||||||
|
if(radius<uRingBounds.x || radius>uRingBounds.y)return vec4(0.0);
|
||||||
|
float u=(radius-uRingBounds.x)/(uRingBounds.y-uRingBounds.x);
|
||||||
|
return textureLod(uRingProfile,vec2(u,0.5),0.0);
|
||||||
|
}
|
||||||
|
float ringTransmission(vec3 bodyPosition,vec3 bodyLightDirection) {
|
||||||
|
if(uHasRings==0 || abs(bodyLightDirection.y)<0.000001)return 1.0;
|
||||||
|
float t=-bodyPosition.y/bodyLightDirection.y;
|
||||||
|
if(t<=0.0)return 1.0;
|
||||||
|
float r=length((bodyPosition+bodyLightDirection*t).xz);
|
||||||
|
float tau=(ringMaterial(r-uRingShadowSoftness).a+2.0*ringMaterial(r).a+ringMaterial(r+uRingShadowSoftness).a)*0.25;
|
||||||
|
return exp(-tau/max(abs(bodyLightDirection.y),0.035));
|
||||||
|
}
|
||||||
54
src/Universe.App/Graphics/Shaders/ring.frag.glsl
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "camera-ray.glsl"
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform vec3 uCameraBody;
|
||||||
|
uniform mat4 uWorldToBody;
|
||||||
|
uniform float uPolarScale;
|
||||||
|
uniform vec3 uRingLighting;
|
||||||
|
uniform vec3 uRingPhase;
|
||||||
|
uniform vec3 uRockRange;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
out vec4 fragColor;
|
||||||
|
float planetShadow(vec3 p,vec3 l) {
|
||||||
|
vec3 o=p/vec3(1,uPolarScale,1),d=l/vec3(1,uPolarScale,1);
|
||||||
|
float t=max(0.0,-dot(o,d)/dot(d,d));
|
||||||
|
float miss=length(o+d*t);
|
||||||
|
return smoothstep(1.0-uRingShadowSoftness,1.0+uRingShadowSoftness,miss);
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 ray=cameraRay(vUv),rd=normalize(mat3(uWorldToBody)*ray);
|
||||||
|
if(abs(rd.y)<1e-9)discard;
|
||||||
|
float t=-uCameraBody.y/rd.y;
|
||||||
|
if(t<=0.0)discard;
|
||||||
|
vec3 p=uCameraBody+rd*t;
|
||||||
|
float r=length(p.xz);
|
||||||
|
if(r<uRingBounds.x || r>uRingBounds.y)discard;
|
||||||
|
// Derivatives choose the radial profile's mip level, avoiding distant moire.
|
||||||
|
float x=(r-uRingBounds.x)/(uRingBounds.y-uRingBounds.x);
|
||||||
|
vec4 material=texture(uRingProfile,vec2(x,0.5));
|
||||||
|
float optical=material.a;
|
||||||
|
if(optical<0.0001)discard;
|
||||||
|
float meters=t*uRadius;
|
||||||
|
float resolved=1.0-smoothstep(uRockRange.x,uRockRange.y,meters);
|
||||||
|
float dust=mix(1.0,uRockRange.z,resolved);
|
||||||
|
float alpha=(1.0-exp(-optical/max(abs(rd.y),0.025)))*dust;
|
||||||
|
vec3 light=vec3(uRingLighting.y);
|
||||||
|
for(int i=0;i<2;i++) {
|
||||||
|
if(i>=uLightCount)break;
|
||||||
|
vec3 l=uLightDirections[i];
|
||||||
|
float g=uRingPhase.x,mu=dot(rd,l);
|
||||||
|
float phase=(1.0-g*g)/pow(max(0.06,1.0+g*g-2.0*g*mu),1.5);
|
||||||
|
float sameSide=step(0.0,-rd.y*l.y);
|
||||||
|
float reflection=(0.20+0.80*sqrt(abs(l.y)))*sameSide;
|
||||||
|
float transmission=uRingPhase.y*phase*(1.0-sameSide)*exp(-optical*0.6);
|
||||||
|
light+=uLightColors[i]*uLightFlux[i]*uRingLighting.x*planetShadow(p,l)*(reflection+transmission);
|
||||||
|
}
|
||||||
|
fragColor=vec4(material.rgb*light,alpha);
|
||||||
|
gl_FragDepth=log2(1.0+meters*max(dot(ray,uForward),0.001))/log2(1.0+uFar);
|
||||||
|
}
|
||||||
16
src/Universe.App/Graphics/Shaders/ring.vert.glsl
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=0) in vec3 aPosition;
|
||||||
|
uniform mat4 uModel;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform float uFar;
|
||||||
|
out vec3 vRingPosition;
|
||||||
|
out vec3 vWorldPosition;
|
||||||
|
out float vFragDepth;
|
||||||
|
void main() {
|
||||||
|
vRingPosition=aPosition;
|
||||||
|
vWorldPosition=(uModel*vec4(aPosition,1.0)).xyz;
|
||||||
|
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
|
||||||
|
vFragDepth=clip.w+1.0;
|
||||||
|
gl_Position=clip;
|
||||||
|
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
|
||||||
|
}
|
||||||
37
src/Universe.App/Graphics/Shaders/rock-common.glsl
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
uniform float uFar;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform float uPolarScale;
|
||||||
|
uniform vec3 uLightDirections[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightFlux[2];
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uRockLighting;
|
||||||
|
uniform vec3 uRockMaterial;
|
||||||
|
uniform mat4 uWorldToBody;
|
||||||
|
uniform vec3 uCameraBody;
|
||||||
|
uniform vec3 uRockLod;
|
||||||
|
uniform vec3 uRockFade;
|
||||||
|
float rockMeshWeight(float distanceMeters,float pixels) {
|
||||||
|
return (1.0-smoothstep(uRockLod.x-uRockLod.y,uRockLod.x+uRockLod.y,distanceMeters))*smoothstep(uRockLod.z*0.65,uRockLod.z*1.35,pixels);
|
||||||
|
}
|
||||||
|
float rockPlanetShadow(vec3 p,vec3 l) {
|
||||||
|
vec3 o=p/vec3(1,uPolarScale,1),d=l/vec3(1,uPolarScale,1);
|
||||||
|
float t=max(0.0,-dot(o,d)/dot(d,d));
|
||||||
|
return smoothstep(1.0-uRingShadowSoftness,1.0+uRingShadowSoftness,length(o+d*t));
|
||||||
|
}
|
||||||
|
vec3 rockIllumination(vec3 color,vec3 normal,vec3 worldPosition,float seed) {
|
||||||
|
vec3 p=uCameraBody+mat3(uWorldToBody)*worldPosition/uRadius;
|
||||||
|
vec3 n=normalize(mat3(uWorldToBody)*normal);
|
||||||
|
vec3 view=normalize(mat3(uWorldToBody)*(-worldPosition));
|
||||||
|
vec3 light=color*uRockLighting.y;
|
||||||
|
for(int i=0;i<2;i++) {
|
||||||
|
if(i>=uLightCount)break;
|
||||||
|
vec3 l=uLightDirections[i];
|
||||||
|
float selfShadow=ringTransmission(p,l);
|
||||||
|
float visibility=rockPlanetShadow(p,l)*mix(0.3,1.0,selfShadow);
|
||||||
|
float diffuse=max(dot(n,l),0.0);
|
||||||
|
float ice=pow(max(dot(n,normalize(l+view)),0.0),mix(70.0,8.0,uRockMaterial.y))*uRockMaterial.z;
|
||||||
|
light+=(color*diffuse+vec3(ice))*uLightColors[i]*uLightFlux[i]*uRockLighting.x*visibility;
|
||||||
|
}
|
||||||
|
return light;
|
||||||
|
}
|
||||||
13
src/Universe.App/Graphics/Shaders/rock-point.frag.glsl
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec3 vColor;
|
||||||
|
in float vAlpha;
|
||||||
|
in float vClipDepth;
|
||||||
|
uniform float uFar;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
float r=length(gl_PointCoord*2.0-1.0);
|
||||||
|
if(r>1.0 || vAlpha<0.0005)discard;
|
||||||
|
float alpha=(1.0-smoothstep(0.4,1.0,r))*vAlpha;
|
||||||
|
fragColor=vec4(vColor,alpha);
|
||||||
|
gl_FragDepth=log2(vClipDepth)/log2(1.0+uFar);
|
||||||
|
}
|
||||||
28
src/Universe.App/Graphics/Shaders/rock-point.vert.glsl
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=2)in vec3 aOffset;
|
||||||
|
layout(location=3)in float aRadius;
|
||||||
|
layout(location=4)in vec3 aColor;
|
||||||
|
layout(location=5)in float aSeed;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform mat4 uBodyToWorld;
|
||||||
|
uniform vec3 uAnchorWorld;
|
||||||
|
uniform float uPixelScale;
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
#include "rock-common.glsl"
|
||||||
|
out vec3 vColor;
|
||||||
|
out float vAlpha;
|
||||||
|
out float vClipDepth;
|
||||||
|
void main() {
|
||||||
|
vec3 position=uAnchorWorld+mat3(uBodyToWorld)*aOffset;
|
||||||
|
float distanceMeters=length(position);
|
||||||
|
float pixels=2.0*aRadius*uPixelScale/max(0.1,distanceMeters);
|
||||||
|
float weight=1.0-rockMeshWeight(distanceMeters,pixels);
|
||||||
|
weight*=1.0-smoothstep(uRockFade.x,uRockFade.y,distanceMeters);
|
||||||
|
float size=clamp(pixels,1.0,24.0);
|
||||||
|
vAlpha=weight*min(1.0,pixels*pixels/(size*size));
|
||||||
|
vColor=rockIllumination(aColor,normalize(-position),position,aSeed);
|
||||||
|
gl_Position=uViewProjection*vec4(position,1);
|
||||||
|
vClipDepth=1.0+max(0.0,gl_Position.w);
|
||||||
|
gl_Position.z=(2.0*log2(vClipDepth)/log2(1.0+uFar)-1.0)*gl_Position.w;
|
||||||
|
gl_PointSize=size;
|
||||||
|
}
|
||||||
35
src/Universe.App/Graphics/Shaders/rock.frag.glsl
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
#include "rock-common.glsl"
|
||||||
|
#include "asteroid-shape.glsl"
|
||||||
|
in vec3 vPosition;
|
||||||
|
in vec3 vNormal;
|
||||||
|
in vec3 vRockLocal;
|
||||||
|
in vec3 vColor;
|
||||||
|
flat in float vSeed;
|
||||||
|
in float vWeight;
|
||||||
|
in float vClipDepth;
|
||||||
|
flat in vec4 vRotation;
|
||||||
|
out vec4 fragColor;
|
||||||
|
uniform int uDebugNormals;
|
||||||
|
void main() {
|
||||||
|
float dither=fract(sin(dot(gl_FragCoord.xy,vec2(12.9898,78.233)))*43758.5453);
|
||||||
|
vec3 d=normalize(vRockLocal);
|
||||||
|
float footprint=max(length(dFdx(d)),length(dFdy(d)));
|
||||||
|
vec3 axes=asteroidAxes(vSeed);
|
||||||
|
vec3 p=asteroidPosition(d,vSeed)+d*axes*asteroidMicroHeight(d,vSeed,footprint);
|
||||||
|
vec3 localNormal=normalize(cross(dFdx(p),dFdy(p)));
|
||||||
|
if(dot(localNormal,d)<0.0)localNormal=-localNormal;
|
||||||
|
localNormal=normalize(mix(normalize(d/axes),localNormal,uAsteroidNoise.z));
|
||||||
|
vec3 rotated=localNormal+2.0*cross(vRotation.xyz,cross(vRotation.xyz,localNormal)+vRotation.w*localNormal);
|
||||||
|
vec3 normal=normalize(transpose(mat3(uWorldToBody))*rotated);
|
||||||
|
float geology=noise3(d*19.0+vSeed*119.0);
|
||||||
|
float crater=asteroidCraters(d,uAsteroidCraters.x,vSeed)*uAsteroidCraters.x;
|
||||||
|
float cavity=mix(.55,1.0,smoothstep(-.25,0.0,crater));
|
||||||
|
if(dither>vWeight)discard;
|
||||||
|
fragColor=vec4(rockIllumination(vColor*(.88+geology*.17)*cavity,normal,vPosition,vSeed),1.0);
|
||||||
|
if(uDebugNormals==1)fragColor=vec4(normal*.5+.5,1);
|
||||||
|
if(uDebugNormals==2)fragColor=vec4(1,1,1,1);
|
||||||
|
gl_FragDepth=log2(vClipDepth)/log2(1.0+uFar);
|
||||||
|
}
|
||||||
40
src/Universe.App/Graphics/Shaders/rock.vert.glsl
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
layout(location=0)in vec3 aPosition;
|
||||||
|
layout(location=1)in vec3 aNormal;
|
||||||
|
layout(location=2)in vec3 aOffset;
|
||||||
|
layout(location=3)in float aRadius;
|
||||||
|
layout(location=4)in vec3 aColor;
|
||||||
|
layout(location=5)in float aSeed;
|
||||||
|
layout(location=6)in vec4 aRotation;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform mat4 uBodyToWorld;
|
||||||
|
uniform vec3 uAnchorWorld;
|
||||||
|
uniform float uPixelScale;
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
#include "rock-common.glsl"
|
||||||
|
#include "asteroid-shape.glsl"
|
||||||
|
out vec3 vPosition;
|
||||||
|
out vec3 vNormal;
|
||||||
|
out vec3 vRockLocal;
|
||||||
|
out vec3 vColor;
|
||||||
|
flat out float vSeed;
|
||||||
|
out float vWeight;
|
||||||
|
out float vClipDepth;
|
||||||
|
flat out vec4 vRotation;
|
||||||
|
vec3 rotate(vec3 v) { return v+2.0*cross(aRotation.xyz,cross(aRotation.xyz,v)+aRotation.w*v); }
|
||||||
|
void main() {
|
||||||
|
vec3 center=uAnchorWorld+mat3(uBodyToWorld)*aOffset;
|
||||||
|
float distanceMeters=length(center);
|
||||||
|
float pixels=2.0*aRadius*uPixelScale/max(0.1,distanceMeters);
|
||||||
|
vWeight=rockMeshWeight(distanceMeters,pixels)*(1.0-smoothstep(uRockFade.x,uRockFade.y,distanceMeters));
|
||||||
|
vec3 local=rotate(asteroidPosition(normalize(aPosition),aSeed))*aRadius;
|
||||||
|
vPosition=center+mat3(uBodyToWorld)*local;
|
||||||
|
vNormal=normalize(mat3(uBodyToWorld)*rotate(aNormal));
|
||||||
|
vRockLocal=aPosition;
|
||||||
|
vRotation=aRotation;
|
||||||
|
vColor=aColor;vSeed=aSeed;
|
||||||
|
gl_Position=uViewProjection*vec4(vPosition,1);
|
||||||
|
vClipDepth=1.0+max(0.0,gl_Position.w);
|
||||||
|
gl_Position.z=(2.0*log2(vClipDepth)/log2(1.0+uFar)-1.0)*gl_Position.w;
|
||||||
|
}
|
||||||
32
src/Universe.App/Graphics/Shaders/sky.frag.glsl
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform vec3 uForward;
|
||||||
|
uniform vec3 uRight;
|
||||||
|
uniform vec3 uUp;
|
||||||
|
uniform float uAspect;
|
||||||
|
uniform float uTanHalfFov;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uStarDirections[2];
|
||||||
|
uniform vec3 uStarColors[2];
|
||||||
|
uniform float uStarAngles[2];
|
||||||
|
uniform float uTime;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec2 xy=(vUv*2.0-1.0)*uTanHalfFov;
|
||||||
|
vec3 rd=normalize(uForward+uRight*xy.x*uAspect+uUp*xy.y);
|
||||||
|
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)));
|
||||||
|
vec3 color=vec3(0.00013,0.00020,0.00042)+vec3(0.0013,0.0016,0.0026)*band*dust;
|
||||||
|
for(int i=0;i<2;i++) {
|
||||||
|
if(i>=uLightCount) break;
|
||||||
|
float angle=acos(clamp(dot(rd,uStarDirections[i]),-1.0,1.0));
|
||||||
|
float r=angle/max(uStarAngles[i],0.00001);
|
||||||
|
float corona=exp(-max(0.0,r-1.0)*6.0)*0.12;
|
||||||
|
corona+=exp(-max(0.0,r-1.0)*1.5)*0.004;
|
||||||
|
corona+=0.0002*exp(-angle*28.0);
|
||||||
|
float strands=0.85+0.15*noise3(rd*18.0+uTime*0.015);
|
||||||
|
color+=uStarColors[i]*corona*strands;
|
||||||
|
}
|
||||||
|
fragColor=vec4(color,1.0);
|
||||||
|
}
|
||||||
33
src/Universe.App/Graphics/Shaders/star.frag.glsl
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
in vec3 vDirection;
|
||||||
|
in vec3 vWorldPosition;
|
||||||
|
in float vFragDepth;
|
||||||
|
uniform vec3 uColor;
|
||||||
|
uniform vec3 uNoiseOffset;
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uFar;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec3 d=normalize(vDirection);
|
||||||
|
vec3 p=d*2.4+uNoiseOffset+vec3(0.0,uTime*0.018,uTime*0.006);
|
||||||
|
vec3 q=vec3(fbm(p),fbm(p+vec3(8.3,1.7,4.6)),fbm(p+vec3(1.2,9.4,13.7)));
|
||||||
|
vec3 w=vec3(fbm(p*1.8+q*4.2+vec3(5.2,1.3,0.7)),fbm(p*1.8+q*4.2+vec3(9.1,6.7,3.4)),fbm(p*1.8+q*4.2+vec3(2.3,8.7,9.2)));
|
||||||
|
float field=fbm(p*0.8+w*2.0+q*3.0);
|
||||||
|
float activeRegions=fbm(d*3.0+uNoiseOffset*0.2);
|
||||||
|
float filaments=pow(1.0-abs(noise3(p*4.0+w*6.0)),8.0);
|
||||||
|
float cells=noise3(d*200.0+w*10.0)*0.5+0.5;
|
||||||
|
float hot=smoothstep(-0.14,0.23,field)*(0.25+0.75*smoothstep(-0.30,0.32,activeRegions));
|
||||||
|
float spots=1.0-smoothstep(0.37,0.57,fbm(d*7.0+uNoiseOffset*0.2))*0.82;
|
||||||
|
vec3 cool=vec3(0.55,0.13,0.085);
|
||||||
|
vec3 bright=vec3(2.5,1.50,0.45);
|
||||||
|
vec3 plasma=mix(cool,bright,pow(hot,0.85));
|
||||||
|
plasma+=vec3(0.8,0.48,0.10)*filaments*0.25;
|
||||||
|
plasma*=mix(0.96,1.04,cells)*spots;
|
||||||
|
float blueStar=smoothstep(0.88,1.16,uColor.b/max(uColor.r,0.01));
|
||||||
|
plasma=mix(plasma,vec3(0.65,1.05,2.15)*(0.65+hot*2.4+filaments*0.5),blueStar);
|
||||||
|
float mu=max(dot(d,normalize(-vWorldPosition)),0.0);
|
||||||
|
float limb=0.09+0.91*pow(mu,0.80);
|
||||||
|
fragColor=vec4(plasma*limb,1.0);
|
||||||
|
gl_FragDepth=clamp(log2(max(0.0001,vFragDepth))/log2(uFar+1.0),0.0,1.0);
|
||||||
|
}
|
||||||
17
src/Universe.App/Graphics/Shaders/star.vert.glsl
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=0) in vec3 aPosition;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform vec3 uCenter;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform float uFar;
|
||||||
|
out vec3 vDirection;
|
||||||
|
out vec3 vWorldPosition;
|
||||||
|
out float vFragDepth;
|
||||||
|
void main() {
|
||||||
|
vDirection=aPosition;
|
||||||
|
vWorldPosition=uCenter+aPosition*uRadius;
|
||||||
|
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
|
||||||
|
vFragDepth=clip.w+1.0;
|
||||||
|
gl_Position=clip;
|
||||||
|
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
|
||||||
|
}
|
||||||
69
src/Universe.App/Graphics/Shaders/sun-glow.frag.glsl
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#version 330 core
|
||||||
|
in vec2 vUv;
|
||||||
|
uniform sampler2D uScene;
|
||||||
|
uniform sampler2D uDepth;
|
||||||
|
uniform int uStarCount;
|
||||||
|
uniform float uAspect;
|
||||||
|
uniform vec3 uStarPosition[2]; // UV position, logarithmic front depth
|
||||||
|
uniform vec3 uStarShape[2]; // apparent radius, intensity, minimum core radius
|
||||||
|
uniform vec3 uStarHalo[2]; // halo radius, halo strength, ray strength
|
||||||
|
uniform vec3 uStarRays[2]; // ray length, ray count, ray sharpness
|
||||||
|
uniform vec3 uStarGhosts[2]; // strength, spacing, chromatic aberration
|
||||||
|
uniform vec3 uStarColors[2];
|
||||||
|
uniform float uStarRotation[2];
|
||||||
|
out vec4 fragColor;
|
||||||
|
float starVisibility(int i) {
|
||||||
|
vec2 center=uStarPosition[i].xy;
|
||||||
|
if(any(lessThan(center,vec2(0)))||any(greaterThan(center,vec2(1))))return 0.0;
|
||||||
|
float visible=0.0,total=0.0;
|
||||||
|
for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++) {
|
||||||
|
vec2 uv=center+vec2(x/uAspect,y)*uStarShape[i].x*.55;
|
||||||
|
if(any(lessThan(uv,vec2(0)))||any(greaterThan(uv,vec2(1))))continue;
|
||||||
|
float weight=(x==0 && y==0)?4.0:1.0;
|
||||||
|
visible+=step(uStarPosition[i].z-.000002,texture(uDepth,uv).r)*texture(uScene,uv).a*weight;total+=weight;
|
||||||
|
}
|
||||||
|
return visible/max(1.0,total);
|
||||||
|
}
|
||||||
|
float aperture(vec2 p,float radius) {
|
||||||
|
float angle=atan(p.y,p.x);
|
||||||
|
float hex=cos(floor(.5+angle/1.04719755)*1.04719755-angle)*length(p);
|
||||||
|
return (1.0-smoothstep(radius*.86,radius,hex))*.12+exp(-pow((hex-radius*.88)/(radius*.09),2.0))*.24;
|
||||||
|
}
|
||||||
|
vec3 sunGlare(int i) {
|
||||||
|
float intensity=uStarShape[i].y*starVisibility(i);
|
||||||
|
if(intensity<.00001)return vec3(0.0);
|
||||||
|
vec2 metric=vec2(uAspect,1.0),center=uStarPosition[i].xy;
|
||||||
|
vec2 p=(vUv-center)*metric;
|
||||||
|
float r=length(p),angle=atan(p.y,p.x)+uStarRotation[i];
|
||||||
|
float core=max(uStarShape[i].z,uStarShape[i].x*.075);
|
||||||
|
float halo=uStarHalo[i].x;
|
||||||
|
float central=14.0*exp(-r*r/max(1e-12,core*core));
|
||||||
|
float glow=exp(-r/max(.0001,halo))*uStarHalo[i].y/(1.0+r/max(core*16.0,.0001));
|
||||||
|
float rays=pow(.5+.5*cos(angle*uStarRays[i].y),uStarRays[i].z);
|
||||||
|
float smallRays=pow(.5+.5*cos(angle*(uStarRays[i].y+6.0)+1.1),uStarRays[i].z*1.5)*.25;
|
||||||
|
float taper=exp(-r/max(.0001,uStarRays[i].x))/(1.0+r/max(core*5.0,.0001));
|
||||||
|
vec3 color=uStarColors[i]*(central+glow+(rays+smallRays)*taper*uStarHalo[i].z);
|
||||||
|
vec2 axis=vec2(.5)-center;
|
||||||
|
float axisLength=length(axis*metric);
|
||||||
|
float ghostWeight=smoothstep(.025,.15,axisLength)*uStarGhosts[i].x;
|
||||||
|
for(int ghost=0;ghost<6;ghost++) {
|
||||||
|
float g=float(ghost);
|
||||||
|
vec2 ghostCenter=center+axis*(.7+g*uStarGhosts[i].y);
|
||||||
|
float radius=.013+.006*g;
|
||||||
|
vec2 q=(vUv-ghostCenter)*metric;
|
||||||
|
float chroma=uStarGhosts[i].z;
|
||||||
|
vec3 tint=.45+.45*cos(vec3(.1,2.2,4.3)+g*1.37);
|
||||||
|
vec3 spectral=vec3(aperture(q,radius*(1.0-chroma*.15)),aperture(q,radius),aperture(q,radius*(1.0+chroma*.15)));
|
||||||
|
color+=spectral*tint*ghostWeight/(1.0+g*.3);
|
||||||
|
}
|
||||||
|
vec2 ringCenter=vec2(.5)+axis*.25;
|
||||||
|
float ringDistance=length((vUv-ringCenter)*metric);
|
||||||
|
float rainbow=exp(-pow((ringDistance-.27)/.012,2.0));
|
||||||
|
color+=rainbow*(.5+.5*cos(vec3(0,2,4)+ringDistance*160.0))*ghostWeight*.035;
|
||||||
|
return color*intensity;
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
vec3 color=texture(uScene,vUv).rgb;
|
||||||
|
for(int i=0;i<2;i++){if(i>=uStarCount)break;color+=sunGlare(i);}
|
||||||
|
fragColor=vec4(color,1.0);
|
||||||
|
}
|
||||||
190
src/Universe.App/Graphics/Shaders/terrain.frag.glsl
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#version 330 core
|
||||||
|
#include "noise.glsl"
|
||||||
|
#include "planet-height.glsl"
|
||||||
|
#include "cloud-common.glsl"
|
||||||
|
in vec3 vWorldPosition;
|
||||||
|
in vec3 vPlanetLocal;
|
||||||
|
in vec3 vNormal;
|
||||||
|
in float vFragDepth;
|
||||||
|
in vec3 vMaterialPosition;
|
||||||
|
uniform int uMaterialsEnabled;
|
||||||
|
uniform sampler2D uFlatColor;
|
||||||
|
uniform sampler2D uFlatNormal;
|
||||||
|
uniform sampler2D uSlopeColor;
|
||||||
|
uniform sampler2D uSlopeNormal;
|
||||||
|
uniform vec3 uMaterialSettings;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform sampler2D uSurfaceTexture;
|
||||||
|
uniform int uHasSurfaceTexture;
|
||||||
|
uniform int uSurfaceType;
|
||||||
|
uniform vec3 uCloudParameters;
|
||||||
|
#include "ring-optics.glsl"
|
||||||
|
uniform vec3 uSurfaceColor;
|
||||||
|
uniform vec3 uAtmosphereColor;
|
||||||
|
uniform int uPlanetIndex;
|
||||||
|
uniform int uLightCount;
|
||||||
|
uniform vec3 uLightPositions[2];
|
||||||
|
uniform vec3 uLightColors[2];
|
||||||
|
uniform float uLightLuminosities[2];
|
||||||
|
uniform int uOccluderCount;
|
||||||
|
uniform vec3 uOccluderCenters[16];
|
||||||
|
uniform float uOccluderRadii[16];
|
||||||
|
uniform int uTerrainShadows;
|
||||||
|
uniform int uDebugSkipSurfaceNoise;
|
||||||
|
uniform int uDebugDisableBodyShadows;
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
vec4 triColor(sampler2D image,vec3 p,vec3 weights) {
|
||||||
|
return texture(image,p.yz)*weights.x+texture(image,p.xz)*weights.y+texture(image,p.xy)*weights.z;
|
||||||
|
}
|
||||||
|
vec3 triNormal(sampler2D image,vec3 p,vec3 n,vec3 weights) {
|
||||||
|
vec3 x=texture(image,p.yz).xyz*2.0-1.0;
|
||||||
|
vec3 y=texture(image,p.xz).xyz*2.0-1.0;
|
||||||
|
vec3 z=texture(image,p.xy).xyz*2.0-1.0;
|
||||||
|
return normalize(vec3(sign(n.x)*x.z,x.x,x.y)*weights.x+
|
||||||
|
vec3(y.x,sign(n.y)*y.z,y.y)*weights.y+
|
||||||
|
vec3(z.x,z.y,sign(n.z)*z.z)*weights.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
float mountainShadow(vec3 p, vec3 lightDir, float sunHeight) {
|
||||||
|
if(uTerrainShadows==0 || sunHeight>0.8) return 1.0;
|
||||||
|
float visibility=1.0;
|
||||||
|
float t=0.000055;
|
||||||
|
for(int i=0;i<6;i++) {
|
||||||
|
vec3 q=p+lightDir*t;
|
||||||
|
float ground=1.0+surfaceElevation(normalize(q));
|
||||||
|
float clearance=length(q)-ground+0.000055;
|
||||||
|
visibility=min(visibility,clamp(18.0*clearance/t,0.0,1.0));
|
||||||
|
if(visibility<0.015) break;
|
||||||
|
t*=2.75;
|
||||||
|
}
|
||||||
|
return visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec3 cloudMaterial(vec3 d) {
|
||||||
|
float latitude=asin(clamp(d.y,-1.0,1.0));
|
||||||
|
float turbulence=fbm(d*6.0+uNoiseOffset+vec3(uCloudParameters.z*0.006,0,0));
|
||||||
|
float wisps=fbm(d*27.0+uNoiseOffset+turbulence*3.0);
|
||||||
|
if(uSurfaceType==2) return uSurfaceColor*(0.8+0.4*smoothstep(-0.5,0.5,turbulence+wisps*0.4));
|
||||||
|
float bands=sin(latitude*uCloudParameters.x+turbulence*2.5+wisps*0.8)*0.5+0.5;
|
||||||
|
float contrast=uSurfaceType==4 ? 0.20 : 0.72;
|
||||||
|
vec3 cloud=mix(uSurfaceColor*(1.0-contrast*0.5),uSurfaceColor*(1.0+contrast*0.5),bands);
|
||||||
|
vec3 pale=uSurfaceType==4 ? vec3(0.5,0.73,0.78) : vec3(0.79,0.69,0.50);
|
||||||
|
cloud=mix(cloud,pale,smoothstep(0.66,0.98,bands)*0.45);
|
||||||
|
float longitude=atan(d.z,d.x);
|
||||||
|
float dx=atan(sin(longitude+0.35),cos(longitude+0.35));
|
||||||
|
vec2 storm=vec2(dx*cos(latitude),latitude+0.36)/vec2(0.20,0.075);
|
||||||
|
float spot=exp(-dot(storm,storm))*uCloudParameters.y;
|
||||||
|
vec3 stormColor=uSurfaceType==4 ? uSurfaceColor*0.25 : vec3(0.57,0.20,0.10);
|
||||||
|
return mix(cloud,stormColor,spot)*(0.95+wisps*0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 radial=normalize(vPlanetLocal);
|
||||||
|
vec3 localDirection=bodyDirection(radial);
|
||||||
|
vec3 textureColor=uHasSurfaceTexture!=0 ? texture(uSurfaceTexture,planetUv(localDirection)).rgb : vec3(0.0);
|
||||||
|
float ocean=(uHasSurfaceTexture!=0 && uSurfaceType==1 && textureColor.b>textureColor.g*1.2 && textureColor.b>textureColor.r*1.4) ? 1.0:0.0;
|
||||||
|
// Vertices already carry terrain normals from the collision height field. Reusing them
|
||||||
|
// avoids three full procedural height evaluations for every shaded pixel.
|
||||||
|
float elevation=length(vPlanetLocal)-1.0;
|
||||||
|
float height=uAmplitude>0.0 ? elevation/uAmplitude : 0.0;
|
||||||
|
vec3 normal=normalize(vNormal);
|
||||||
|
// Small rock relief is filtered by the pixel footprint to avoid distant sparkling.
|
||||||
|
vec3 rockPosition=radial*18000.0+uNoiseOffset;
|
||||||
|
float grainFootprint=length(fwidth(rockPosition));
|
||||||
|
float grain=0.0,erosion=0.0;
|
||||||
|
if(uDebugSkipSurfaceNoise==0) {
|
||||||
|
grain=noise3(rockPosition)*(1.0-smoothstep(0.55,1.0,grainFootprint))
|
||||||
|
+0.25*noise3(rockPosition*3.1)*(1.0-smoothstep(0.55,1.0,grainFootprint*3.1));
|
||||||
|
erosion=fbm(radial*2400.0+uNoiseOffset)*0.8;
|
||||||
|
}
|
||||||
|
float detail=grain*0.10+erosion;
|
||||||
|
vec3 sigmaX=dFdx(vWorldPosition), sigmaY=dFdy(vWorldPosition);
|
||||||
|
vec3 r1=cross(sigmaY,normal), r2=cross(normal,sigmaX);
|
||||||
|
float det=dot(sigmaX,r1);
|
||||||
|
float microWeight=1.0-smoothstep(0.4,3.0,grainFootprint);
|
||||||
|
vec3 gradient=(dFdx(detail)*r1+dFdy(detail)*r2)/max(abs(det),0.0001)*sign(det);
|
||||||
|
float microAmplitude=uHasSurfaceTexture!=0 ? min(uRadius*0.000006,uAmplitude*uRadius*0.04) : uRadius*0.000006;
|
||||||
|
normal=normalize(normal-gradient*microAmplitude*microWeight*(1.0-ocean));
|
||||||
|
if(uSurfaceType>=2 || ocean>0.5)normal=radial;
|
||||||
|
float slope=clamp(1.0-dot(normal,radial),0.0,1.0);
|
||||||
|
float geology=0.0,deposits=0.5,strata=0.5;
|
||||||
|
if(uDebugSkipSurfaceNoise==0) {
|
||||||
|
geology=fbm(radial*31.0+uNoiseOffset);
|
||||||
|
deposits=noise3(radial*170.0+uNoiseOffset);
|
||||||
|
strata=sin(height*150.0+noise3(radial*76.0)*2.2)*0.5+0.5;
|
||||||
|
}
|
||||||
|
vec3 mineral=mix(uSurfaceColor*0.55,uSurfaceColor*1.62,smoothstep(-0.45,0.5,geology));
|
||||||
|
mineral=mix(mineral,vec3(0.28,0.23,0.20),smoothstep(0.08,0.48,slope)*0.67);
|
||||||
|
mineral*=mix(0.91,1.07,strata*(0.35+slope*0.65));
|
||||||
|
mineral*=0.96+0.08*deposits+grain*0.045;
|
||||||
|
float snowLine=0.60-abs(radial.y)*0.10;
|
||||||
|
float snow=uDebugSkipSurfaceNoise!=0 ? 0.0 : smoothstep(snowLine,snowLine+0.09,height+geology*0.11)*(1.0-smoothstep(0.16,0.48,slope));
|
||||||
|
mineral=mix(mineral,vec3(0.75,0.81,0.84),snow*0.85);
|
||||||
|
float ao=mix(0.58,1.0,smoothstep(-0.16,0.38,height));
|
||||||
|
if(uHasSurfaceTexture!=0) { mineral=textureColor; ao=1.0; snow=0.0; }
|
||||||
|
if(uSurfaceType>=2) { mineral=cloudMaterial(localDirection); ao=1.0; snow=0.0; }
|
||||||
|
float materialRoughness=.8;
|
||||||
|
if(uMaterialsEnabled!=0 && ocean<.5) {
|
||||||
|
float strength=uMaterialSettings.z*(1.0-smoothstep(uMaterialSettings.y*.35,uMaterialSettings.y,length(vWorldPosition)));
|
||||||
|
if(strength>.001) {
|
||||||
|
vec3 bodyNormal=normalize(mat3(uWorldToBody)*normal);
|
||||||
|
vec3 weights=pow(abs(bodyNormal),vec3(4.0)); weights/=max(.0001,weights.x+weights.y+weights.z);
|
||||||
|
vec3 position=vMaterialPosition/uMaterialSettings.x;
|
||||||
|
float rock=smoothstep(.04,.23,slope);
|
||||||
|
vec4 material=mix(triColor(uFlatColor,position,weights),triColor(uSlopeColor,position,weights),rock);
|
||||||
|
mineral=mix(mineral,material.rgb,strength);
|
||||||
|
materialRoughness=mix(.8,material.a,strength);
|
||||||
|
vec3 detail=mix(triNormal(uFlatNormal,position,bodyNormal,weights),triNormal(uSlopeNormal,position,bodyNormal,weights),rock);
|
||||||
|
normal=normalize(mix(normal,transpose(mat3(uWorldToBody))*detail,strength*.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec3 light=mineral*(vec3(0.007)+uAtmosphereColor*0.018)*ao;
|
||||||
|
vec3 viewDirection=normalize(-vWorldPosition);
|
||||||
|
for(int l=0;l<2;l++) {
|
||||||
|
if(l>=uLightCount) break;
|
||||||
|
vec3 toLight=uLightPositions[l]-vWorldPosition;
|
||||||
|
float distanceToLight=length(toLight);
|
||||||
|
vec3 ld=toLight/max(distanceToLight,1.0);
|
||||||
|
float sunHeight=dot(radial,ld);
|
||||||
|
float visibility=smoothstep(-0.025,0.018,sunHeight);
|
||||||
|
if(visibility>0.001) {
|
||||||
|
visibility*=cloudShadow(vWorldPosition,ld);
|
||||||
|
if(uDebugDisableBodyShadows==0) {
|
||||||
|
visibility*=ringTransmission(mat3(uWorldToBody)*vPlanetLocal,mat3(uWorldToBody)*ld);
|
||||||
|
for(int j=0;j<16;j++) {
|
||||||
|
if(j>=uOccluderCount) break;
|
||||||
|
if(j==uPlanetIndex) continue;
|
||||||
|
vec3 o=uOccluderCenters[j]-vWorldPosition;
|
||||||
|
float along=dot(o,ld);
|
||||||
|
if(along>0.0 && along<distanceToLight) {
|
||||||
|
float miss=length(o-ld*along);
|
||||||
|
float soft=uOccluderRadii[j]*0.045;
|
||||||
|
visibility*=smoothstep(uOccluderRadii[j]-soft,uOccluderRadii[j]+soft,miss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(visibility>0.01 && uSurfaceType<2) visibility*=mountainShadow(radial*(1.0+elevation)+normal*0.000060,ld,sunHeight);
|
||||||
|
float au=distanceToLight/149597870700.0;
|
||||||
|
float flux=clamp(uLightLuminosities[l]/max(au*au,0.06),0.13,7.0);
|
||||||
|
float diffuse=max(dot(normal,ld),0.0);
|
||||||
|
vec3 halfDir=normalize(viewDirection+ld);
|
||||||
|
float specular=pow(max(dot(normal,halfDir),0.0),mix(90.0,12.0,materialRoughness))*0.045;
|
||||||
|
if(ocean>0.5) {
|
||||||
|
float fresnel=0.02+0.98*pow(1.0-max(dot(normal,viewDirection),0.0),5.0);
|
||||||
|
specular=pow(max(dot(normal,halfDir),0.0),180.0)*(0.15+fresnel)*3.0;
|
||||||
|
}
|
||||||
|
vec3 sunColor=uLightColors[l];
|
||||||
|
// Low sunlight loses blue to the atmosphere before reaching the ground.
|
||||||
|
vec3 extinction=exp(-vec3(0.035,0.07,0.16)/(max(sunHeight,0.04)+0.08));
|
||||||
|
light+=(mineral*diffuse*(1.0-ocean*0.25)+specular)*sunColor*extinction*flux*1.65*visibility;
|
||||||
|
float skyHemisphere=0.22+0.78*max(dot(normal,radial),0.0);
|
||||||
|
vec3 skyIrradiance=mix(vec3(0.22,0.35,0.70),uAtmosphereColor,0.5);
|
||||||
|
light+=mineral*skyIrradiance*0.40*sqrt(max(sunHeight,0.0))*sqrt(flux)*ao*skyHemisphere;
|
||||||
|
}
|
||||||
|
fragColor=vec4(max(light,vec3(0.0)),1.0);
|
||||||
|
gl_FragDepth=clamp(log2(max(0.0001,vFragDepth))/log2(uFar+1.0),0.0,1.0);
|
||||||
|
}
|
||||||
26
src/Universe.App/Graphics/Shaders/terrain.vert.glsl
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#version 330 core
|
||||||
|
layout(location=0) in vec3 aPosition;
|
||||||
|
layout(location=1) in vec3 aNormal;
|
||||||
|
layout(location=2) in vec3 aDirection;
|
||||||
|
uniform vec3 uChunkPosition;
|
||||||
|
uniform vec3 uChunkOrigin;
|
||||||
|
uniform float uRadius;
|
||||||
|
uniform mat4 uViewProjection;
|
||||||
|
uniform float uFar;
|
||||||
|
uniform mat4 uWorldToBody;
|
||||||
|
uniform vec3 uMaterialAnchor;
|
||||||
|
out vec3 vWorldPosition;
|
||||||
|
out vec3 vPlanetLocal;
|
||||||
|
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;
|
||||||
|
vec4 clip=uViewProjection*vec4(vWorldPosition,1.0);
|
||||||
|
vFragDepth=clip.w+1.0;
|
||||||
|
gl_Position=clip;
|
||||||
|
gl_Position.z=(2.0*log2(max(0.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
|
||||||
|
}
|
||||||
80
src/Universe.App/Graphics/SurfaceMaterials.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.App.Graphics;
|
||||||
|
|
||||||
|
/// <summary>Content catalog and lazily uploaded reusable material textures.</summary>
|
||||||
|
internal sealed unsafe class SurfaceMaterials : IDisposable
|
||||||
|
{
|
||||||
|
private sealed record Entry(string Id, string Color, string Normal, string Roughness);
|
||||||
|
private sealed record Catalog(Entry[] Materials, string[] TerrestrialFlat, string[] RockyFlat, string[] Slopes);
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly string _directory;
|
||||||
|
private readonly Catalog _catalog;
|
||||||
|
private readonly Dictionary<string, Entry> _entries;
|
||||||
|
private readonly Dictionary<string, (uint Color, uint Normal)> _textures = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public SurfaceMaterials(GL gl, string directory)
|
||||||
|
{
|
||||||
|
_gl = gl; _directory = Path.GetFullPath(directory);
|
||||||
|
var path = Path.Combine(_directory, "materials.json");
|
||||||
|
_catalog = JsonSerializer.Deserialize<Catalog>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||||
|
?? throw new InvalidDataException("Empty material catalog.");
|
||||||
|
_entries = _catalog.Materials.ToDictionary(e => e.Id, StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (_catalog.TerrestrialFlat.Length == 0 || _catalog.RockyFlat.Length == 0 || _catalog.Slopes.Length == 0
|
||||||
|
|| _catalog.TerrestrialFlat.Concat(_catalog.RockyFlat).Concat(_catalog.Slopes).Any(id => !_entries.ContainsKey(id)))
|
||||||
|
throw new InvalidDataException("Material catalog contains empty pools or unknown material IDs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint FlatColor, uint FlatNormal, uint SlopeColor, uint SlopeNormal) ForPlanet(PlanetData planet)
|
||||||
|
{
|
||||||
|
var pool = planet.SurfaceType == PlanetSurfaceType.Terrestrial ? _catalog.TerrestrialFlat : _catalog.RockyFlat;
|
||||||
|
var flat = planet.Materials.FlatMaterial ?? pool[(int)(DeterministicRandom.Mix(planet.Seed ^ 0xF1A7UL) % (ulong)pool.Length)];
|
||||||
|
var slope = planet.Materials.SlopeMaterial ?? _catalog.Slopes[(int)(DeterministicRandom.Mix(planet.Seed ^ 0x5109EUL) % (ulong)_catalog.Slopes.Length)];
|
||||||
|
var a = Get(flat); var b = Get(slope);
|
||||||
|
return (a.Color, a.Normal, b.Color, b.Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Resolve(string relative)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Material paths must be relative to Content.");
|
||||||
|
var full = Path.GetFullPath(Path.Combine(_directory, relative));
|
||||||
|
if (!full.StartsWith(_directory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("Material paths must stay inside Content.");
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
private (uint Color, uint Normal) Get(string id)
|
||||||
|
{
|
||||||
|
if (_textures.TryGetValue(id, out var existing)) return existing;
|
||||||
|
if (!_entries.TryGetValue(id, out var entry)) throw new InvalidDataException($"Unknown surface material '{id}'.");
|
||||||
|
var color = RasterImage.Load(Resolve(entry.Color));
|
||||||
|
var roughness = RasterImage.Load(Resolve(entry.Roughness));
|
||||||
|
var normal = RasterImage.Load(Resolve(entry.Normal));
|
||||||
|
if (color.Width != roughness.Width || color.Height != roughness.Height || color.Width != normal.Width || color.Height != normal.Height)
|
||||||
|
throw new InvalidDataException($"Material '{id}' images must have equal dimensions.");
|
||||||
|
for (var i = 0; i < color.Width * color.Height; i++) color.Rgba[i * 4 + 3] = roughness.Rgba[i * 4];
|
||||||
|
var result = (Upload(color, true), Upload(normal, false));
|
||||||
|
_textures.Add(id, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint Upload(RasterImage image, bool srgb)
|
||||||
|
{
|
||||||
|
var handle = _gl.GenTexture(); _gl.BindTexture(GLEnum.Texture2D, handle);
|
||||||
|
fixed (byte* pixels = image.Rgba)
|
||||||
|
_gl.TexImage2D(GLEnum.Texture2D, 0, srgb ? InternalFormat.Srgb8Alpha8 : InternalFormat.Rgba8,
|
||||||
|
(uint)image.Width, (uint)image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixels);
|
||||||
|
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapS, (int)GLEnum.Repeat);
|
||||||
|
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureWrapT, (int)GLEnum.Repeat);
|
||||||
|
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMinFilter, (int)GLEnum.LinearMipmapLinear);
|
||||||
|
_gl.TexParameter(GLEnum.Texture2D, GLEnum.TextureMagFilter, (int)GLEnum.Linear);
|
||||||
|
_gl.GenerateMipmap(GLEnum.Texture2D); return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var value in _textures.Values) { _gl.DeleteTexture(value.Color); _gl.DeleteTexture(value.Normal); }
|
||||||
|
}
|
||||||
|
}
|
||||||
1051
src/Universe.App/Graphics/UniverseRenderer.cs
Normal file
4
src/Universe.App/Program.cs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
using Universe.App;
|
||||||
|
|
||||||
|
using var application = new UniverseApplication();
|
||||||
|
application.Run();
|
||||||
224
src/Universe.App/Runtime/BenchmarkRunner.cs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.Physics;
|
||||||
|
using Universe.Core.World;
|
||||||
|
|
||||||
|
namespace Universe.App.Runtime;
|
||||||
|
|
||||||
|
/// <summary>Repeatable camera path and frame-time capture for a selected planetary terrain scene.</summary>
|
||||||
|
internal sealed class BenchmarkRunner
|
||||||
|
{
|
||||||
|
private const double SpaceSeconds = 8;
|
||||||
|
private const double DescentSeconds = 40;
|
||||||
|
private const double SurfaceSeconds = 24;
|
||||||
|
private const double TotalSeconds = SpaceSeconds + DescentSeconds + SurfaceSeconds;
|
||||||
|
private const double GraphSampleSeconds = 0.2;
|
||||||
|
private const double BrakingAltitudeMeters = 10_000;
|
||||||
|
|
||||||
|
private readonly StarSystemData _system;
|
||||||
|
private readonly PlanetData _planet;
|
||||||
|
private readonly UniversePosition _systemCenter;
|
||||||
|
private readonly UniversePosition _body;
|
||||||
|
private readonly UniversePosition _returnPosition;
|
||||||
|
private readonly DVector3 _returnForward;
|
||||||
|
private readonly DVector3 _returnUp;
|
||||||
|
private readonly double _cellSize;
|
||||||
|
private readonly DVector3 _targetRadial;
|
||||||
|
private readonly DVector3 _spaceRadial;
|
||||||
|
private readonly DVector3 _spaceUp;
|
||||||
|
private readonly DVector3 _orbitTangent;
|
||||||
|
private readonly DVector3 _orbitBitangent;
|
||||||
|
private readonly List<double> _frameRates = [];
|
||||||
|
private readonly List<double> _graph = [];
|
||||||
|
private double _elapsed;
|
||||||
|
private double _renderElapsed;
|
||||||
|
private double _sampleElapsed;
|
||||||
|
private double _sampleFpsSum;
|
||||||
|
private int _sampleFrameCount;
|
||||||
|
private bool _completionFrameRecorded;
|
||||||
|
private bool _stopped;
|
||||||
|
|
||||||
|
public BenchmarkRunner(StarSystemData system, PlanetData planet, double cellSizeMeters, FlyCamera camera,
|
||||||
|
UniversePosition returnPosition, DVector3 returnForward, DVector3 returnUp)
|
||||||
|
{
|
||||||
|
_system = system;
|
||||||
|
_planet = planet;
|
||||||
|
_cellSize = cellSizeMeters;
|
||||||
|
_systemCenter = UniversePosition.AtCellCenter(system.Coordinate, cellSizeMeters);
|
||||||
|
_body = _systemCenter.Add(_planet.PositionMeters, cellSizeMeters);
|
||||||
|
_returnPosition = returnPosition;
|
||||||
|
_returnForward = returnForward.Normalized();
|
||||||
|
_returnUp = returnUp.Normalized();
|
||||||
|
|
||||||
|
DVector3 bodyDirection;
|
||||||
|
if (_planet.Id.Equals("earth", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// The installed Copernicus tile covers the Alps. Its longitude is mirrored
|
||||||
|
// into the planet's local direction frame by the shared heightmap mapping.
|
||||||
|
const double latitude = 46.5;
|
||||||
|
const double longitude = -7.5;
|
||||||
|
var lat = latitude * System.Math.PI / 180;
|
||||||
|
var lon = longitude * System.Math.PI / 180;
|
||||||
|
bodyDirection = new(System.Math.Cos(lat) * System.Math.Cos(lon), System.Math.Sin(lat),
|
||||||
|
System.Math.Cos(lat) * System.Math.Sin(lon));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var random = new DeterministicRandom(DeterministicRandom.Mix(_planet.Seed ^ 0x42454E43484D4152UL));
|
||||||
|
var y = random.NextDouble() * 2 - 1;
|
||||||
|
var longitude = random.NextDouble() * System.Math.Tau;
|
||||||
|
var latitudeRadius = System.Math.Sqrt(1 - y * y);
|
||||||
|
bodyDirection = new(latitudeRadius * System.Math.Cos(longitude), y, latitudeRadius * System.Math.Sin(longitude));
|
||||||
|
}
|
||||||
|
_targetRadial = _planet.Orientation.ToWorld(bodyDirection).Normalized();
|
||||||
|
_spaceRadial = _targetRadial;
|
||||||
|
_spaceUp = Perpendicular(-_spaceRadial);
|
||||||
|
_orbitTangent = Perpendicular(_targetRadial);
|
||||||
|
_orbitBitangent = DVector3.Cross(_targetRadial, _orbitTangent).Normalized();
|
||||||
|
|
||||||
|
var initialRadius = _planet.RadiusMeters * 4.8;
|
||||||
|
camera.SetPlayerPose(_body.Add(_spaceRadial * initialRadius, cellSizeMeters), -_spaceRadial, _spaceUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning => !_stopped && _elapsed < TotalSeconds;
|
||||||
|
public bool IsComplete => !_stopped && _elapsed >= TotalSeconds;
|
||||||
|
public bool HasRun => true;
|
||||||
|
public double ElapsedSeconds => System.Math.Min(_elapsed, TotalSeconds);
|
||||||
|
public double DurationSeconds => TotalSeconds;
|
||||||
|
public long GraphRevision { get; private set; }
|
||||||
|
public IReadOnlyList<double> GraphSamples => _graph;
|
||||||
|
public string TargetLabel => _planet.Id.Equals("earth", StringComparison.OrdinalIgnoreCase) ? "EARTH" : _planet.Name;
|
||||||
|
public double TargetEarthRadii => _planet.RadiusMeters / UniverseScale.EarthRadiusMeters;
|
||||||
|
public double CurrentFps { get; private set; }
|
||||||
|
public double AverageFps => _renderElapsed <= 0 ? 0 : _frameRates.Count / _renderElapsed;
|
||||||
|
public double MinimumFps => _frameRates.Count == 0 ? 0 : _frameRates.Min();
|
||||||
|
public double OnePercentLowFps
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_frameRates.Count == 0) return 0;
|
||||||
|
var count = System.Math.Max(1, (int)System.Math.Ceiling(_frameRates.Count * .01));
|
||||||
|
return _frameRates.Order().Take(count).Average();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Phase => _stopped ? "STOPPED"
|
||||||
|
: IsComplete ? "COMPLETE"
|
||||||
|
: _elapsed < SpaceSeconds ? "SPACE VIEW"
|
||||||
|
: _elapsed < SpaceSeconds + DescentSeconds ? "DESCENT"
|
||||||
|
: "GROUND WALK";
|
||||||
|
|
||||||
|
public void Stop() => _stopped = true;
|
||||||
|
|
||||||
|
public void Update(double deltaSeconds, FlyCamera camera)
|
||||||
|
{
|
||||||
|
if (!IsRunning || !double.IsFinite(deltaSeconds) || deltaSeconds <= 0) return;
|
||||||
|
_elapsed = System.Math.Min(TotalSeconds, _elapsed + System.Math.Min(deltaSeconds, .1));
|
||||||
|
|
||||||
|
UniversePosition proposed;
|
||||||
|
DVector3 radial;
|
||||||
|
DVector3 forward;
|
||||||
|
DVector3 up;
|
||||||
|
var finalRadius = TerrainHeight.Sample(_planet, _targetRadial) + 1.8;
|
||||||
|
|
||||||
|
if (_elapsed <= SpaceSeconds)
|
||||||
|
{
|
||||||
|
radial = _spaceRadial;
|
||||||
|
proposed = _body.Add(radial * (_planet.RadiusMeters * 4.8), _cellSize);
|
||||||
|
forward = -radial;
|
||||||
|
up = _spaceUp;
|
||||||
|
}
|
||||||
|
else if (_elapsed < SpaceSeconds + DescentSeconds)
|
||||||
|
{
|
||||||
|
var t = System.Math.Clamp((_elapsed - SpaceSeconds) / DescentSeconds, 0, 1);
|
||||||
|
// Keep the long, high-speed part of the approach far above the surface.
|
||||||
|
// The final 10 km get their own slow curve so collision resolution never
|
||||||
|
// has to snap a fast-moving camera down onto the ground.
|
||||||
|
var startRadius = _planet.RadiusMeters * 4.8;
|
||||||
|
var brakingRadius = finalRadius + BrakingAltitudeMeters;
|
||||||
|
var radius = t < .5
|
||||||
|
? startRadius + (brakingRadius - startRadius) * Smooth(t * 2)
|
||||||
|
: finalRadius + BrakingAltitudeMeters * (1 - SmootherStep((t - .5) * 2));
|
||||||
|
radial = _targetRadial;
|
||||||
|
proposed = _body.Add(radial * radius, _cellSize);
|
||||||
|
var landingLook = (_orbitBitangent * System.Math.Cos(.25) - radial * System.Math.Sin(.25)).Normalized();
|
||||||
|
forward = Slerp(-radial, landingLook, SmootherStep(System.Math.Clamp((t - .28) / .72, 0, 1)));
|
||||||
|
up = (radial - forward * DVector3.Dot(radial, forward)).Normalized();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var t = System.Math.Clamp((_elapsed - SpaceSeconds - DescentSeconds) / SurfaceSeconds, 0, 1);
|
||||||
|
var angle = t * System.Math.Tau;
|
||||||
|
var walkRadius = 16 * Smooth(t); // Starts at the exact landing point without a lateral jump.
|
||||||
|
var orbitAngle = walkRadius / _planet.RadiusMeters;
|
||||||
|
radial = (_targetRadial * System.Math.Cos(orbitAngle)
|
||||||
|
+ (_orbitTangent * System.Math.Cos(angle) + _orbitBitangent * System.Math.Sin(angle)) * System.Math.Sin(orbitAngle)).Normalized();
|
||||||
|
var terrainRadius = TerrainHeight.Sample(_planet, radial);
|
||||||
|
var radius = System.Math.Max(terrainRadius + 1.8, _planet.RadiusMeters + 1.8);
|
||||||
|
proposed = _body.Add(radial * radius, _cellSize);
|
||||||
|
var walkingDirection = (_orbitTangent * -System.Math.Sin(angle) + _orbitBitangent * System.Math.Cos(angle)).Normalized();
|
||||||
|
var headTurn = .48 * System.Math.Sin(t * System.Math.Tau * 1.5);
|
||||||
|
var pitch = -.25 + .18 * System.Math.Sin(t * System.Math.Tau * 2);
|
||||||
|
var lookingDirection = RotateAround(walkingDirection, radial, headTurn);
|
||||||
|
forward = (lookingDirection * System.Math.Cos(pitch) + radial * System.Math.Sin(pitch)).Normalized();
|
||||||
|
up = (radial - forward * DVector3.Dot(radial, forward)).Normalized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (up.LengthSquared < .1) up = Perpendicular(forward);
|
||||||
|
var collision = PlanetCollisionSolver.Sweep(camera.Position, proposed, _systemCenter, _system.Planets,
|
||||||
|
_cellSize, cameraRadiusMeters: 1.5, samples: 32);
|
||||||
|
if (_elapsed >= TotalSeconds)
|
||||||
|
{
|
||||||
|
camera.SetPlayerPose(_returnPosition, _returnForward, _returnUp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var finalPosition = proposed;
|
||||||
|
if (collision.Collided)
|
||||||
|
{
|
||||||
|
var contactRadial = collision.Position.RelativeTo(_body, _cellSize).Normalized();
|
||||||
|
finalPosition = _body.Add(contactRadial * (TerrainHeight.Sample(_planet, contactRadial) + 1.8), _cellSize);
|
||||||
|
}
|
||||||
|
camera.SetPlayerPose(finalPosition, forward, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordFrame(double deltaSeconds)
|
||||||
|
{
|
||||||
|
if (_stopped || _elapsed >= TotalSeconds && _completionFrameRecorded || !double.IsFinite(deltaSeconds) || deltaSeconds <= 0)
|
||||||
|
return;
|
||||||
|
var frameSeconds = System.Math.Clamp(deltaSeconds, .0001, 1);
|
||||||
|
CurrentFps = 1 / frameSeconds;
|
||||||
|
_frameRates.Add(CurrentFps);
|
||||||
|
_renderElapsed += frameSeconds;
|
||||||
|
_sampleElapsed += frameSeconds;
|
||||||
|
_sampleFpsSum += CurrentFps;
|
||||||
|
_sampleFrameCount++;
|
||||||
|
if (_sampleElapsed >= GraphSampleSeconds || _elapsed >= TotalSeconds)
|
||||||
|
{
|
||||||
|
_graph.Add(_sampleFrameCount == 0 ? CurrentFps : _sampleFpsSum / _sampleFrameCount);
|
||||||
|
_sampleElapsed = 0;
|
||||||
|
_sampleFpsSum = 0;
|
||||||
|
_sampleFrameCount = 0;
|
||||||
|
GraphRevision++;
|
||||||
|
}
|
||||||
|
if (_elapsed >= TotalSeconds) _completionFrameRecorded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Smooth(double t) => t * t * (3 - 2 * t);
|
||||||
|
private static double SmootherStep(double t) => t * t * t * (t * (t * 6 - 15) + 10);
|
||||||
|
private static DVector3 Perpendicular(DVector3 direction) =>
|
||||||
|
DVector3.Cross(direction, System.Math.Abs(direction.Y) < .9 ? new(0, 1, 0) : new DVector3(1, 0, 0)).Normalized();
|
||||||
|
|
||||||
|
private static DVector3 RotateAround(DVector3 vector, DVector3 axis, double angle) =>
|
||||||
|
vector * System.Math.Cos(angle) + DVector3.Cross(axis, vector) * System.Math.Sin(angle)
|
||||||
|
+ axis * DVector3.Dot(axis, vector) * (1 - System.Math.Cos(angle));
|
||||||
|
|
||||||
|
private static DVector3 Slerp(DVector3 from, DVector3 to, double t)
|
||||||
|
{
|
||||||
|
var dot = System.Math.Clamp(DVector3.Dot(from, to), -1, 1);
|
||||||
|
if (dot > .9995) return DVector3.Lerp(from, to, t).Normalized();
|
||||||
|
var angle = System.Math.Acos(dot);
|
||||||
|
var tangent = (to - from * dot).Normalized();
|
||||||
|
if (tangent.LengthSquared < .1) tangent = Perpendicular(from);
|
||||||
|
return (from * System.Math.Cos(angle * t) + tangent * System.Math.Sin(angle * t)).Normalized();
|
||||||
|
}
|
||||||
|
}
|
||||||
379
src/Universe.App/Runtime/FlyCamera.cs
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.Physics;
|
||||||
|
using Universe.Core.World;
|
||||||
|
using Universe.Core.Configuration;
|
||||||
|
|
||||||
|
namespace Universe.App.Runtime;
|
||||||
|
|
||||||
|
public sealed class FlyCamera
|
||||||
|
{
|
||||||
|
private DVector3 _forward = new(0, 0, -1);
|
||||||
|
private DVector3 _up = new(0, 1, 0);
|
||||||
|
private FocusFlight? _flight;
|
||||||
|
private double _flightElapsed;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Position = initialPosition;
|
||||||
|
CellSizeMeters = cellSizeMeters;
|
||||||
|
SpeedMetersPerSecond = 250_000;
|
||||||
|
}
|
||||||
|
public UniversePosition Position { get; private set; }
|
||||||
|
public double CellSizeMeters { get; }
|
||||||
|
public double SpeedMetersPerSecond { get; private set; }
|
||||||
|
public bool IsFocusing => _flight is not null;
|
||||||
|
public DVector3 Forward => _forward;
|
||||||
|
public DVector3 Right => DVector3.Cross(_forward, _up).Normalized();
|
||||||
|
public DVector3 Up => DVector3.Cross(Right, _forward).Normalized();
|
||||||
|
|
||||||
|
public void Rotate(double mouseDeltaX, double mouseDeltaY)
|
||||||
|
{
|
||||||
|
if (IsFocusing) return;
|
||||||
|
_forward = RotateAround(_forward, Up, -mouseDeltaX * 0.0022).Normalized();
|
||||||
|
var axis = Right;
|
||||||
|
var beforePitchUp = Up;
|
||||||
|
_forward = RotateAround(_forward, 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 SetPlayerPose(UniversePosition position, DVector3 forward, DVector3 up)
|
||||||
|
{
|
||||||
|
_flight = null;
|
||||||
|
Position = position; _forward = forward.Normalized(); _up = up.Normalized();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInitialView(StarSystemData system)
|
||||||
|
{
|
||||||
|
var planet = system.Planets[0];
|
||||||
|
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters);
|
||||||
|
var sun = (system.Stars[0].PositionMeters - planet.PositionMeters).Normalized();
|
||||||
|
var side = Perpendicular(sun);
|
||||||
|
var radial = (sun * 0.42 + side * 0.907).Normalized();
|
||||||
|
Position = center.Add(planet.PositionMeters + radial * (planet.RadiusMeters * 2.85), CellSizeMeters);
|
||||||
|
_forward = -radial;
|
||||||
|
_up = new(0, 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInitialView(StarSystemData system, StartupConfiguration startup)
|
||||||
|
{
|
||||||
|
var planet = system.Planets.FirstOrDefault(p => p.Id == startup.PlanetId)
|
||||||
|
?? throw new InvalidDataException($"Startup planet '{startup.PlanetId}' does not exist in system '{system.Id}'.");
|
||||||
|
var longitude = startup.LongitudeDegrees * System.Math.PI / 180;
|
||||||
|
var latitude = startup.LatitudeDegrees * System.Math.PI / 180;
|
||||||
|
var bodyDirection = new DVector3(System.Math.Cos(latitude) * System.Math.Cos(longitude),
|
||||||
|
System.Math.Sin(latitude), System.Math.Cos(latitude) * System.Math.Sin(longitude));
|
||||||
|
var radial = planet.Orientation.ToWorld(bodyDirection).Normalized();
|
||||||
|
var distance = System.Math.Max(planet.RadiusMeters * startup.DistanceInRadii, planet.MaximumTerrainRadiusMeters + 10);
|
||||||
|
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters);
|
||||||
|
Position = center.Add(planet.PositionMeters + radial * distance, CellSizeMeters);
|
||||||
|
_forward = -radial;
|
||||||
|
_up = new(0, 1, 0);
|
||||||
|
if (System.Math.Abs(DVector3.Dot(_forward, _up)) > 0.999) _up = Perpendicular(_forward);
|
||||||
|
SpeedMetersPerSecond = startup.SpeedMetersPerSecond;
|
||||||
|
_defaultFieldOfView = FieldOfViewRadians = startup.FieldOfViewDegrees * System.Math.PI / 180;
|
||||||
|
_flight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestFocus(IEnumerable<StarSystemData> systems) => RequestPlanetView(systems, false);
|
||||||
|
public void RequestFocus(IEnumerable<StarSystemData> systems, string planetId) => RequestPlanetView(systems, false, planetId);
|
||||||
|
public void RequestSurfaceView(IEnumerable<StarSystemData> systems) => RequestPlanetView(systems, true);
|
||||||
|
|
||||||
|
public void RequestCloudView(IEnumerable<StarSystemData> systems)
|
||||||
|
{
|
||||||
|
var available = systems.ToArray();
|
||||||
|
var target = available.SelectMany(s => s.Planets.Where(p => p.Clouds is { Enabled: true, Coverage: > 0 }).Select(p => (System: s, Planet: p)))
|
||||||
|
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
|
||||||
|
if (target.Planet is null) return;
|
||||||
|
var planet = target.Planet; var cloud = planet.Clouds!;
|
||||||
|
var body = World(target.System, planet.PositionMeters);
|
||||||
|
var sun = (target.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
|
||||||
|
var side = Perpendicular(sun); var across = DVector3.Cross(sun, side);
|
||||||
|
var current = Position.RelativeTo(body, CellSizeMeters).Normalized();
|
||||||
|
var radial = (sun * .4 + side * .9165).Normalized(); var best = double.NegativeInfinity;
|
||||||
|
for (var i = 0; i < 120; i++)
|
||||||
|
{
|
||||||
|
var angle = i * System.Math.Tau / 120;
|
||||||
|
var candidate = (sun * .4 + (side * System.Math.Cos(angle) + across * System.Math.Sin(angle)) * .9165).Normalized();
|
||||||
|
var score = DVector3.Dot(candidate, current) + (planet.OceanMask?.Sample(planet.Orientation.ToBody(candidate)) ?? 0) * 2;
|
||||||
|
if (score <= best) continue;
|
||||||
|
radial = candidate; best = score;
|
||||||
|
}
|
||||||
|
var height = System.Math.Max(planet.RadiusMeters + cloud.BaseAltitudeMeters + cloud.ThicknessMeters + 500, TerrainHeight.Sample(planet, radial) + 500);
|
||||||
|
var forward = ((sun - radial * DVector3.Dot(sun, radial)).Normalized() - radial * .22).Normalized();
|
||||||
|
Departure? departure = null;
|
||||||
|
var escape = available.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.03)
|
||||||
|
departure = new(origin, start.Normalized(), body.Add(radial * height, CellSizeMeters).RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 1.04);
|
||||||
|
}
|
||||||
|
BeginFlight(body, radial, height, System.Math.Max(height * 1.04, planet.MaximumTerrainRadiusMeters * 1.04), forward, radial, 100, departure: departure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestOceanView(IEnumerable<StarSystemData> systems)
|
||||||
|
{
|
||||||
|
var available = systems.ToArray();
|
||||||
|
var target = available.SelectMany(s => s.Planets.Where(p => p.Ocean is { Enabled: true }).Select(p => (System: s, Planet: p)))
|
||||||
|
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
|
||||||
|
if (target.Planet is null) return;
|
||||||
|
var planet = target.Planet; var ocean = planet.Ocean!;
|
||||||
|
var body = World(target.System, planet.PositionMeters);
|
||||||
|
var sun = (target.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
|
||||||
|
var side = Perpendicular(sun); var bitangent = DVector3.Cross(sun, side);
|
||||||
|
var currentRadial = Position.RelativeTo(body, CellSizeMeters).Normalized();
|
||||||
|
var radial = DVector3.Zero; var score = double.NegativeInfinity;
|
||||||
|
for (var i = 0; i < 120; i++)
|
||||||
|
{
|
||||||
|
var angle = i * System.Math.Tau / 120;
|
||||||
|
var candidate = ((side * System.Math.Cos(angle) + bitangent * System.Math.Sin(angle)) * .925 + sun * .38).Normalized();
|
||||||
|
if (planet.OceanMask is { } mask && mask.Sample(planet.Orientation.ToBody(candidate)) < .99) continue;
|
||||||
|
if (TerrainHeight.Sample(planet, candidate) > planet.RadiusMeters + ocean.SeaLevelMeters - 3) continue;
|
||||||
|
var value = DVector3.Dot(candidate, currentRadial);
|
||||||
|
if (value <= score) continue;
|
||||||
|
radial = candidate; score = value;
|
||||||
|
}
|
||||||
|
if (radial == DVector3.Zero) return;
|
||||||
|
var forward = ((sun - radial * DVector3.Dot(sun, radial)).Normalized() - radial * .085).Normalized();
|
||||||
|
Departure? departure = null;
|
||||||
|
var escape = available.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.03)
|
||||||
|
departure = new(origin, start.Normalized(), body.Add(radial * planet.RadiusMeters, CellSizeMeters).RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 1.04);
|
||||||
|
}
|
||||||
|
BeginFlight(body, radial, planet.RadiusMeters + ocean.SeaLevelMeters + System.Math.Max(6, ocean.WaveAmplitudeMeters * 4),
|
||||||
|
planet.MaximumTerrainRadiusMeters * 1.04, forward, radial, 10, 6, departure: departure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestRingView(IEnumerable<StarSystemData> systems)
|
||||||
|
{
|
||||||
|
var available = systems.ToArray();
|
||||||
|
var nearest = available.SelectMany(s => s.Planets.Where(p => p.Rings is not null).Select(p => (System: s, Planet: p)))
|
||||||
|
.MinBy(p => World(p.System, p.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length);
|
||||||
|
if (nearest.Planet is null) return;
|
||||||
|
var planet = nearest.Planet; var rings = planet.Rings!; var preview = rings.Preview;
|
||||||
|
var azimuth = preview.AzimuthDegrees * System.Math.PI / 180;
|
||||||
|
var radius = rings.InnerRadiusMeters + (rings.OuterRadiusMeters - rings.InnerRadiusMeters) * preview.RadiusFraction;
|
||||||
|
var offset = new DVector3(radius * System.Math.Cos(azimuth), preview.HeightMeters, radius * System.Math.Sin(azimuth));
|
||||||
|
var inward = new DVector3(-System.Math.Cos(azimuth), 0, -System.Math.Sin(azimuth));
|
||||||
|
var yaw = RotateAround(inward, new(0, 1, 0), preview.YawOffsetDegrees * System.Math.PI / 180);
|
||||||
|
var pitch = preview.PitchDegrees * System.Math.PI / 180;
|
||||||
|
var forward = planet.Orientation.ToWorld(yaw * System.Math.Cos(pitch) + new DVector3(0, System.Math.Sin(pitch), 0));
|
||||||
|
var escapeBody = available.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);
|
||||||
|
Departure? departure = null;
|
||||||
|
var destination = World(nearest.System, planet.PositionMeters);
|
||||||
|
if (escapeBody.Planet is { } solid)
|
||||||
|
{
|
||||||
|
var origin = World(escapeBody.System, solid.PositionMeters);
|
||||||
|
var start = Position.RelativeTo(origin, CellSizeMeters);
|
||||||
|
if (start.Length < solid.MaximumTerrainRadiusMeters * 6)
|
||||||
|
departure = new(origin, start.Normalized(), destination.RelativeTo(origin, CellSizeMeters).Normalized(), start.Length, solid.MaximumTerrainRadiusMeters * 6);
|
||||||
|
}
|
||||||
|
var up = RotateAround(planet.Orientation.ToWorld(new(0, 1, 0)), forward, preview.RollDegrees * System.Math.PI / 180);
|
||||||
|
BeginFlight(destination, planet.Orientation.ToWorld(offset.Normalized()), offset.Length, rings.OuterRadiusMeters * 2.6,
|
||||||
|
forward, up, preview.SpeedMetersPerSecond,
|
||||||
|
preview.FlightSeconds, preview.FieldOfViewDegrees * System.Math.PI / 180, departure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RequestPlanetView(IEnumerable<StarSystemData> systems, bool surface, string? targetPlanetId = null)
|
||||||
|
{
|
||||||
|
var candidates = systems.SelectMany(system => system.Planets.Select(planet => (System: system, Planet: planet)))
|
||||||
|
.Where(body => targetPlanetId is null || string.Equals(body.Planet.Id, targetPlanetId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length == 0) return;
|
||||||
|
var nearest = candidates
|
||||||
|
.MinBy(body => World(body.System, body.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length - body.Planet.RadiusMeters);
|
||||||
|
var planet = nearest.Planet;
|
||||||
|
var bodyPosition = World(nearest.System, planet.PositionMeters);
|
||||||
|
var sun = (nearest.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
|
||||||
|
var side = Perpendicular(sun);
|
||||||
|
DVector3 radial, forward, up;
|
||||||
|
double finalRadius, speed;
|
||||||
|
if (surface)
|
||||||
|
{
|
||||||
|
var moon = nearest.System.Planets.FirstOrDefault(p => p.ParentPlanetIndex == planet.Index);
|
||||||
|
// A low sun reveals ridge relief. Choose a mountain shoulder instead of a featureless basin.
|
||||||
|
radial = side;
|
||||||
|
var score = double.NegativeInfinity;
|
||||||
|
for (var i = 0; i < 40; i++)
|
||||||
|
{
|
||||||
|
var candidate = (RotateAround(side, sun, i * System.Math.Tau / 40) * 0.9755 + sun * 0.22).Normalized();
|
||||||
|
if (planet.Terrain.HeightMap is { } map && map.Sample(planet.Orientation.ToBody(candidate)) < planet.Terrain.LandThreshold + planet.Terrain.LandBlend)
|
||||||
|
continue;
|
||||||
|
var normal = TerrainHeight.Normal(planet, candidate);
|
||||||
|
var relief = (1 - DVector3.Dot(normal, candidate)) * 0.20
|
||||||
|
+ (TerrainHeight.Sample(planet, candidate) - planet.RadiusMeters) / System.Math.Max(1, planet.TerrainAmplitudeMeters) * 0.80;
|
||||||
|
if (moon is not null)
|
||||||
|
{
|
||||||
|
var moonDirection = (moon.PositionMeters - planet.PositionMeters - candidate * planet.RadiusMeters).Normalized();
|
||||||
|
var elevation = DVector3.Dot(moonDirection, candidate);
|
||||||
|
relief += 1.4 * System.Math.Max(0, 1 - System.Math.Abs(elevation - 0.14) / 0.13);
|
||||||
|
}
|
||||||
|
if (relief <= score) continue;
|
||||||
|
score = relief;
|
||||||
|
radial = candidate;
|
||||||
|
}
|
||||||
|
var terrainTop = TerrainHeight.Sample(planet, radial);
|
||||||
|
var localTangent = Perpendicular(radial);
|
||||||
|
var localBitangent = DVector3.Cross(radial, localTangent);
|
||||||
|
// Clear neighboring ridges as well as the terrain directly under the camera.
|
||||||
|
foreach (var ring in new[] { 0.003, 0.007, 0.012, 0.020 })
|
||||||
|
for (var sample = 0; sample < 16; sample++)
|
||||||
|
{
|
||||||
|
var angle = sample * System.Math.Tau / 16;
|
||||||
|
var d = (radial + (localTangent * System.Math.Cos(angle) + localBitangent * System.Math.Sin(angle)) * ring).Normalized();
|
||||||
|
terrainTop = System.Math.Max(terrainTop, TerrainHeight.Sample(planet, d));
|
||||||
|
}
|
||||||
|
finalRadius = terrainTop + planet.RadiusMeters * 0.0015;
|
||||||
|
var horizontal = (sun - radial * DVector3.Dot(sun, radial)).Normalized();
|
||||||
|
var east = DVector3.Cross(radial, horizontal).Normalized();
|
||||||
|
var look = (horizontal * 0.22 + east * 0.975).Normalized();
|
||||||
|
if (moon is not null)
|
||||||
|
{
|
||||||
|
var toMoon = (moon.PositionMeters - planet.PositionMeters - radial * finalRadius).Normalized();
|
||||||
|
if (DVector3.Dot(toMoon, radial) > 0.02) look = (toMoon - radial * DVector3.Dot(toMoon, radial)).Normalized();
|
||||||
|
}
|
||||||
|
forward = (look - radial * 0.32).Normalized();
|
||||||
|
up = radial;
|
||||||
|
speed = 1_200;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
radial = (sun * 0.42 + side * 0.907).Normalized();
|
||||||
|
finalRadius = System.Math.Max(planet.MaximumTerrainRadiusMeters * 2.85, (planet.Rings?.OuterRadiusMeters ?? 0) * 2.6);
|
||||||
|
forward = -radial;
|
||||||
|
up = new(0, 1, 0);
|
||||||
|
if (planet.Rings is { } rings)
|
||||||
|
{
|
||||||
|
var v = rings.Preview;
|
||||||
|
var pole = planet.Orientation.ToWorld(new(0, 1, 0));
|
||||||
|
var equatorSun = (sun - pole * DVector3.Dot(sun, pole)).Normalized();
|
||||||
|
var heading = RotateAround(equatorSun, pole, v.OverviewSunAngleDegrees * System.Math.PI / 180);
|
||||||
|
var elevation = v.OverviewElevationDegrees * System.Math.PI / 180;
|
||||||
|
radial = (heading * System.Math.Cos(elevation) + pole * System.Math.Sin(elevation)).Normalized();
|
||||||
|
finalRadius = rings.OuterRadiusMeters * v.OverviewDistanceInOuterRadii;
|
||||||
|
forward = -radial;
|
||||||
|
up = RotateAround(pole, forward, v.RollDegrees * System.Math.PI / 180);
|
||||||
|
}
|
||||||
|
speed = 250_000;
|
||||||
|
}
|
||||||
|
BeginFlight(bodyPosition, radial, finalRadius, System.Math.Max(planet.MaximumTerrainRadiusMeters * 5, finalRadius * 1.4), forward, up, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestStarView(IEnumerable<StarSystemData> systems)
|
||||||
|
{
|
||||||
|
var nearest = systems.SelectMany(system => system.Stars.Select(star => (System: system, Star: star)))
|
||||||
|
.MinBy(body => World(body.System, body.Star.PositionMeters).RelativeTo(Position, CellSizeMeters).LengthSquared);
|
||||||
|
if (nearest.Star is null) return;
|
||||||
|
var bodyPosition = World(nearest.System, nearest.Star.PositionMeters);
|
||||||
|
var radial = Position.RelativeTo(bodyPosition, CellSizeMeters).Normalized();
|
||||||
|
BeginFlight(bodyPosition, radial, nearest.Star.RadiusMeters * 2.55, nearest.Star.RadiusMeters * 5, -radial, new(0, 1, 0), 10_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
double duration = 4.5, double? fieldOfView = null, Departure? departure = null)
|
||||||
|
{
|
||||||
|
var travelStart = departure is null ? Position : departure.Body.Add(departure.TargetRadial * departure.ClearanceRadius, CellSizeMeters);
|
||||||
|
var startRadial = travelStart.RelativeTo(body, CellSizeMeters).Normalized();
|
||||||
|
if (startRadial == DVector3.Zero) startRadial = -Forward;
|
||||||
|
_flight = new(travelStart, body, startRadial, radial, approachRadius, finalRadius, Forward, Up, forward, up, speed,
|
||||||
|
duration, FieldOfViewRadians, fieldOfView ?? _defaultFieldOfView, departure);
|
||||||
|
_flightElapsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(double deltaSeconds, DVector3 movement, UniverseNeighborhood neighborhood)
|
||||||
|
{
|
||||||
|
if (deltaSeconds <= 0 || !double.IsFinite(deltaSeconds)) return;
|
||||||
|
var systems = neighborhood.NearbySystems.Append(neighborhood.ActiveSystem).ToArray();
|
||||||
|
if (_flight is { } f)
|
||||||
|
{
|
||||||
|
_flightElapsed += System.Math.Min(deltaSeconds, 0.1);
|
||||||
|
var t = System.Math.Clamp(_flightElapsed / f.Duration, 0, 1);
|
||||||
|
var travelT = f.Departure is null ? t : System.Math.Clamp((t - .28) / .72, 0, 1);
|
||||||
|
UniversePosition proposed;
|
||||||
|
if (f.Departure is { } departure && t < .28)
|
||||||
|
{
|
||||||
|
var direction = t < .12 ? departure.StartRadial : Slerp(departure.StartRadial, departure.TargetRadial, Smooth((t - .12) / .16));
|
||||||
|
var radius = t < .12 ? departure.StartRadius + (departure.ClearanceRadius - departure.StartRadius) * Smooth(t / .12) : departure.ClearanceRadius;
|
||||||
|
proposed = departure.Body.Add(direction * radius, CellSizeMeters);
|
||||||
|
}
|
||||||
|
else if (travelT < 0.38)
|
||||||
|
{
|
||||||
|
var waypoint = f.Body.Add(f.StartRadial * f.ApproachRadius, CellSizeMeters);
|
||||||
|
proposed = f.Start.Add(waypoint.RelativeTo(f.Start, CellSizeMeters) * Smooth(travelT / 0.38), CellSizeMeters);
|
||||||
|
}
|
||||||
|
else if (travelT < 0.7)
|
||||||
|
{
|
||||||
|
var direction = Slerp(f.StartRadial, f.TargetRadial, Smooth((travelT - 0.38) / 0.32));
|
||||||
|
proposed = f.Body.Add(direction * f.ApproachRadius, CellSizeMeters);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var radius = f.ApproachRadius + (f.FinalRadius - f.ApproachRadius) * Smooth((travelT - 0.7) / 0.3);
|
||||||
|
proposed = f.Body.Add(f.TargetRadial * radius, CellSizeMeters);
|
||||||
|
}
|
||||||
|
var result = SweepNeighborhood(Position, proposed, systems);
|
||||||
|
Position = result.Position;
|
||||||
|
_forward = Slerp(f.StartForward, f.TargetForward, Smooth(t));
|
||||||
|
FieldOfViewRadians = f.StartFov + (f.TargetFov - f.StartFov) * Smooth(t);
|
||||||
|
var up = Slerp(f.StartUp, f.TargetUp, Smooth(t));
|
||||||
|
_up = (up - _forward * DVector3.Dot(up, _forward)).Normalized();
|
||||||
|
if (_up.LengthSquared < 0.1) _up = Perpendicular(_forward);
|
||||||
|
if (result.Collided || t >= 1)
|
||||||
|
{
|
||||||
|
_flight = null;
|
||||||
|
SpeedMetersPerSecond = f.FinalSpeed;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (movement.LengthSquared < 1e-12) return;
|
||||||
|
var distance = System.Math.Min(SpeedMetersPerSecond * System.Math.Min(deltaSeconds, 0.1), CellSizeMeters * 0.45);
|
||||||
|
var end = Position.Add(movement.Normalized() * distance, CellSizeMeters);
|
||||||
|
Position = SweepNeighborhood(Position, end, systems).Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CollisionResult SweepNeighborhood(UniversePosition start, UniversePosition end, IReadOnlyList<StarSystemData> systems)
|
||||||
|
{
|
||||||
|
var best = new CollisionResult(end, false, -1);
|
||||||
|
var bestDistance = double.PositiveInfinity;
|
||||||
|
foreach (var system in systems)
|
||||||
|
{
|
||||||
|
var result = PlanetCollisionSolver.Sweep(start, end, UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters), system.Planets, CellSizeMeters);
|
||||||
|
if (!result.Collided) continue;
|
||||||
|
var distance = result.Position.RelativeTo(start, CellSizeMeters).LengthSquared;
|
||||||
|
if (distance >= bestDistance) continue;
|
||||||
|
best = result; bestDistance = distance;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Smooth(double t) => t * t * (3 - 2 * t);
|
||||||
|
private static DVector3 Perpendicular(DVector3 d) => DVector3.Cross(d, System.Math.Abs(d.Y) < 0.9 ? new(0, 1, 0) : new DVector3(1, 0, 0)).Normalized();
|
||||||
|
private static DVector3 RotateAround(DVector3 v, DVector3 axis, double angle) => v * System.Math.Cos(angle) + DVector3.Cross(axis, v) * System.Math.Sin(angle) + axis * DVector3.Dot(axis, v) * (1 - System.Math.Cos(angle));
|
||||||
|
private static DVector3 Slerp(DVector3 a, DVector3 b, double t)
|
||||||
|
{
|
||||||
|
var dot = System.Math.Clamp(DVector3.Dot(a, b), -1, 1);
|
||||||
|
if (dot > 0.9995) return DVector3.Lerp(a, b, t).Normalized();
|
||||||
|
var angle = System.Math.Acos(dot);
|
||||||
|
var tangent = (b - a * dot).Normalized();
|
||||||
|
if (tangent.LengthSquared < 0.1) tangent = Perpendicular(a);
|
||||||
|
return a * System.Math.Cos(angle * t) + tangent * System.Math.Sin(angle * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FocusFlight(UniversePosition Start, UniversePosition Body, DVector3 StartRadial, DVector3 TargetRadial,
|
||||||
|
double ApproachRadius, double FinalRadius, DVector3 StartForward, DVector3 StartUp, DVector3 TargetForward, DVector3 TargetUp, double FinalSpeed,
|
||||||
|
double Duration, double StartFov, double TargetFov, Departure? Departure);
|
||||||
|
private sealed record Departure(UniversePosition Body, DVector3 StartRadial, DVector3 TargetRadial, double StartRadius, double ClearanceRadius);
|
||||||
|
}
|
||||||
136
src/Universe.App/Runtime/PlayerController.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.Physics;
|
||||||
|
using Universe.Core.World;
|
||||||
|
|
||||||
|
namespace Universe.App.Runtime;
|
||||||
|
|
||||||
|
/// <summary>First-person locomotion in a local frame aligned to the nearest solid planet.</summary>
|
||||||
|
public sealed class PlayerController
|
||||||
|
{
|
||||||
|
private const double StandHeight = 1.7, CrouchHeight = 1.0;
|
||||||
|
private readonly double _cellSize;
|
||||||
|
private DVector3 _heading, _velocity;
|
||||||
|
private double _pitch;
|
||||||
|
private bool _jumpHeld;
|
||||||
|
public UniversePosition Position { get; private set; }
|
||||||
|
public bool Grounded { get; private set; }
|
||||||
|
public bool Crouching { get; private set; }
|
||||||
|
public DVector3 Up { get; private set; }
|
||||||
|
public DVector3 Forward => (_heading * System.Math.Cos(_pitch) + Up * System.Math.Sin(_pitch)).Normalized();
|
||||||
|
public double EyeHeight => Crouching ? CrouchHeight : StandHeight;
|
||||||
|
public double Speed => _velocity.Length;
|
||||||
|
|
||||||
|
public PlayerController(UniversePosition position, DVector3 forward, DVector3 fallbackUp, double cellSize, UniverseNeighborhood neighborhood)
|
||||||
|
{
|
||||||
|
Position = position; _cellSize = cellSize;
|
||||||
|
var target = Nearest(neighborhood);
|
||||||
|
Up = target is { } t ? position.RelativeTo(t.Body, cellSize).Normalized() : fallbackUp.Normalized();
|
||||||
|
if (Up == DVector3.Zero) Up = new(0, 1, 0);
|
||||||
|
var projected = forward - Up * DVector3.Dot(forward, Up);
|
||||||
|
_heading = projected.LengthSquared > .0001 ? projected.Normalized() : Tangent(Up);
|
||||||
|
_pitch = System.Math.Clamp(System.Math.Asin(System.Math.Clamp(DVector3.Dot(forward.Normalized(), Up), -1, 1)), -1.5, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Look(double dx, double dy)
|
||||||
|
{
|
||||||
|
var angle = -dx * .0022;
|
||||||
|
_heading = (_heading * System.Math.Cos(angle) + DVector3.Cross(Up, _heading) * System.Math.Sin(angle)).Normalized();
|
||||||
|
_pitch = System.Math.Clamp(_pitch - dy * .0022, -1.5, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Update(double dt, double forward, double side, bool jump, bool crouch, UniverseNeighborhood neighborhood)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(dt) || dt <= 0) return;
|
||||||
|
dt = System.Math.Min(dt, .1);
|
||||||
|
var target = Nearest(neighborhood);
|
||||||
|
if (target is not { } t) { _jumpHeld = jump; return; }
|
||||||
|
var relative = Position.RelativeTo(t.Body, _cellSize);
|
||||||
|
var radial = relative.Normalized();
|
||||||
|
if (radial == DVector3.Zero) radial = Up;
|
||||||
|
Align(radial);
|
||||||
|
var surface = TerrainHeight.Sample(t.Planet, radial);
|
||||||
|
var clearance = relative.Length - surface;
|
||||||
|
var previousHeight = EyeHeight;
|
||||||
|
Crouching = crouch;
|
||||||
|
var eye = EyeHeight;
|
||||||
|
// Tangential motion acquires a tiny outward component as the local radial frame turns.
|
||||||
|
Grounded = clearance <= previousHeight + .08 && DVector3.Dot(_velocity, radial) <= .05;
|
||||||
|
if (Grounded && System.Math.Abs(eye - previousHeight) > .001)
|
||||||
|
{ Position = t.Body.Add(radial * (surface + eye), _cellSize); relative = Position.RelativeTo(t.Body, _cellSize); }
|
||||||
|
|
||||||
|
var right = DVector3.Cross(_heading, radial).Normalized();
|
||||||
|
var input = _heading * System.Math.Clamp(forward, -1, 1) + right * System.Math.Clamp(side, -1, 1);
|
||||||
|
if (input.LengthSquared > 1) input = input.Normalized();
|
||||||
|
var walkSpeed = Crouching ? 1.5 : 4.5;
|
||||||
|
var horizontal = input * walkSpeed;
|
||||||
|
var vertical = DVector3.Dot(_velocity, radial);
|
||||||
|
var gravity = 9.81 * System.Math.Clamp(t.Planet.RadiusMeters / UniverseScale.EarthRadiusMeters, .15, 2.5);
|
||||||
|
if (Grounded && jump && !_jumpHeld) { vertical = 5.2; Grounded = false; }
|
||||||
|
_jumpHeld = jump;
|
||||||
|
|
||||||
|
if (Grounded)
|
||||||
|
{
|
||||||
|
var proposed = relative + horizontal * dt;
|
||||||
|
var nextRadial = proposed.Normalized();
|
||||||
|
var nextSurface = TerrainHeight.Sample(t.Planet, nextRadial);
|
||||||
|
var rise = nextSurface - surface;
|
||||||
|
var move = horizontal.Length * dt;
|
||||||
|
// Allow small steps but prevent walking up steep cliffs.
|
||||||
|
if (rise > .45 || (move > .001 && rise / move > .85)) nextRadial = radial;
|
||||||
|
else if (rise < -.45)
|
||||||
|
{
|
||||||
|
Position = t.Body.Add(proposed.Normalized() * (surface + eye), _cellSize);
|
||||||
|
Grounded = false; vertical = 0;
|
||||||
|
}
|
||||||
|
if (Grounded)
|
||||||
|
{
|
||||||
|
Position = t.Body.Add(nextRadial * (TerrainHeight.Sample(t.Planet, nextRadial) + eye), _cellSize);
|
||||||
|
_velocity = horizontal; Align(nextRadial); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Far above a planet, a bounded descent assist avoids hours of empty free fall.
|
||||||
|
var altitude = System.Math.Max(0, relative.Length - surface - eye);
|
||||||
|
if (altitude > 2000 && vertical <= 0)
|
||||||
|
vertical = -System.Math.Max(-vertical, System.Math.Min(altitude / 5, _cellSize * .02));
|
||||||
|
else vertical = System.Math.Max(vertical - gravity * dt, -180);
|
||||||
|
_velocity = horizontal + radial * vertical;
|
||||||
|
var destination = Position.Add(_velocity * dt, _cellSize);
|
||||||
|
var collision = PlanetCollisionSolver.Sweep(Position, destination,
|
||||||
|
UniversePosition.AtCellCenter(t.System.Coordinate, _cellSize), [t.Planet], _cellSize, eye,
|
||||||
|
_velocity.Length * dt < 100 ? 16 : 128);
|
||||||
|
Position = collision.Position;
|
||||||
|
if (collision.Collided)
|
||||||
|
{
|
||||||
|
var up = Position.RelativeTo(t.Body, _cellSize).Normalized();
|
||||||
|
if (DVector3.Dot(_velocity, up) < 0) _velocity -= up * DVector3.Dot(_velocity, up);
|
||||||
|
Grounded = true;
|
||||||
|
}
|
||||||
|
Align(Position.RelativeTo(t.Body, _cellSize).Normalized());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Align(DVector3 up)
|
||||||
|
{
|
||||||
|
if (up == DVector3.Zero) return;
|
||||||
|
var heading = _heading - up * DVector3.Dot(_heading, up);
|
||||||
|
_heading = heading.LengthSquared > .0001 ? heading.Normalized() : Tangent(up);
|
||||||
|
Up = up;
|
||||||
|
}
|
||||||
|
private static DVector3 Tangent(DVector3 up) => DVector3.Cross(System.Math.Abs(up.Y) < .9 ? new(0, 1, 0) : new DVector3(1, 0, 0), up).Normalized();
|
||||||
|
private (StarSystemData System, PlanetData Planet, UniversePosition Body)? Nearest(UniverseNeighborhood neighborhood)
|
||||||
|
{
|
||||||
|
(StarSystemData System, PlanetData Planet, UniversePosition Body)? best = null;
|
||||||
|
var closest = double.PositiveInfinity;
|
||||||
|
foreach (var system in neighborhood.NearbySystems.Append(neighborhood.ActiveSystem))
|
||||||
|
foreach (var planet in system.Planets)
|
||||||
|
{
|
||||||
|
if (planet.IsGasPlanet) continue;
|
||||||
|
var body = UniversePosition.AtCellCenter(system.Coordinate, _cellSize).Add(planet.PositionMeters, _cellSize);
|
||||||
|
var distance = Position.RelativeTo(body, _cellSize).Length - planet.RadiusMeters;
|
||||||
|
if (distance >= closest) continue;
|
||||||
|
closest = distance; best = (system, planet, body);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/Universe.App/Universe.App.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Universe.Core\Universe.Core.csproj" />
|
||||||
|
<EmbeddedResource Include="Graphics\Shaders\*.glsl" />
|
||||||
|
<Content Include="..\..\config.json" Link="config.json" 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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Windowing.Glfw" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Input.Glfw" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||||
|
<PackageReference Include="Silk.NET.Maths" Version="2.23.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
406
src/Universe.App/UniverseApplication.cs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Numerics;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
using Silk.NET.Maths;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using Silk.NET.Windowing;
|
||||||
|
using Universe.App.Graphics;
|
||||||
|
using Universe.App.Runtime;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.World;
|
||||||
|
using Universe.Core.Configuration;
|
||||||
|
|
||||||
|
namespace Universe.App;
|
||||||
|
|
||||||
|
public sealed class UniverseApplication : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IWindow _window;
|
||||||
|
private readonly UniverseGenerator _generator;
|
||||||
|
private readonly UniverseStreamer _streamer;
|
||||||
|
private readonly IReadOnlyList<string> _planetHotkeys;
|
||||||
|
private readonly StarSystemData _homeSystem;
|
||||||
|
private readonly PlanetData _homeEarth;
|
||||||
|
private readonly UniversePosition _homeCameraPosition;
|
||||||
|
private readonly DVector3 _homeCameraForward;
|
||||||
|
private readonly DVector3 _homeCameraUp;
|
||||||
|
private FlyCamera _camera;
|
||||||
|
private PlayerController? _player;
|
||||||
|
private BenchmarkRunner? _benchmark;
|
||||||
|
private bool _menuOpen;
|
||||||
|
private bool _debugMode;
|
||||||
|
private int _menuSelection;
|
||||||
|
private bool _captureBeforeMenu;
|
||||||
|
private UniverseNeighborhood _neighborhood;
|
||||||
|
private IInputContext? _input;
|
||||||
|
private IKeyboard? _keyboard;
|
||||||
|
private IMouse? _mouse;
|
||||||
|
private UniverseRenderer? _renderer;
|
||||||
|
private Vector2 _mouseDelta;
|
||||||
|
private Vector2 _lastMousePosition;
|
||||||
|
private bool _hasMousePosition;
|
||||||
|
private double _titleTimer;
|
||||||
|
private double _frameTime = 1.0 / 60;
|
||||||
|
private bool _mouseCaptured = !Environment.GetCommandLineArgs().Contains("--free-mouse");
|
||||||
|
private SectorCoordinate _renderedCoordinate;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public UniverseApplication()
|
||||||
|
{
|
||||||
|
var configPath = Path.Combine(AppContext.BaseDirectory, "config.json");
|
||||||
|
var configuration = EngineConfiguration.Load(configPath);
|
||||||
|
_planetHotkeys = configuration.Startup.PlanetHotkeys;
|
||||||
|
var catalogPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(configPath)!, configuration.KnownSolarSystemsFile));
|
||||||
|
var catalog = KnownSolarSystemCatalog.Load(catalogPath, configuration.CellSizeMeters);
|
||||||
|
_generator = new(configuration.Seed, configuration.CellSizeMeters, catalog);
|
||||||
|
_streamer = new(_generator);
|
||||||
|
var initialSystem = configuration.ResolveStartup(catalog).System;
|
||||||
|
_homeSystem = catalog.GetById("sol");
|
||||||
|
_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;
|
||||||
|
var cellCenter = UniversePosition.AtCellCenter(startCoordinate, _generator.CellSizeMeters);
|
||||||
|
var startPosition = cellCenter.Add(new DVector3(0, 0, 4 * UniverseScale.AstronomicalUnitMeters), _generator.CellSizeMeters);
|
||||||
|
_camera = new(startPosition, _generator.CellSizeMeters);
|
||||||
|
_neighborhood = _streamer.Load(startCoordinate);
|
||||||
|
_camera.SetInitialView(_neighborhood.ActiveSystem, configuration.Startup);
|
||||||
|
_homeCameraPosition = _camera.Position;
|
||||||
|
_homeCameraForward = _camera.Forward;
|
||||||
|
_homeCameraUp = _camera.Up;
|
||||||
|
_renderedCoordinate = startCoordinate;
|
||||||
|
|
||||||
|
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"),
|
||||||
|
Size = new Vector2D<int>(1440, 900),
|
||||||
|
VSync = true,
|
||||||
|
PreferredDepthBufferBits = 24,
|
||||||
|
API = new GraphicsAPI(ContextAPI.OpenGL, new APIVersion(3, 3))
|
||||||
|
};
|
||||||
|
_window = Window.Create(options);
|
||||||
|
_window.Load += OnLoad;
|
||||||
|
_window.Update += OnUpdate;
|
||||||
|
_window.Render += OnRender;
|
||||||
|
_window.FramebufferResize += OnFramebufferResize;
|
||||||
|
_window.Closing += OnClosing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Run() => _window.Run();
|
||||||
|
|
||||||
|
private void OnLoad()
|
||||||
|
{
|
||||||
|
var gl = _window.CreateOpenGL();
|
||||||
|
_input = _window.CreateInput();
|
||||||
|
_renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y);
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--terrain-perf-report"))
|
||||||
|
{ GraphicsReport.RunTerrainPerformance(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--menu-report"))
|
||||||
|
{ GraphicsReport.RunMenu(gl, _renderer, _camera, _neighborhood, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--terrain-report"))
|
||||||
|
{ GraphicsReport.RunTerrain(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--cloud-report"))
|
||||||
|
{ GraphicsReport.RunClouds(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--ocean-report"))
|
||||||
|
{ GraphicsReport.RunOcean(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--scene-report"))
|
||||||
|
{ GraphicsReport.RunScene(gl, _renderer, _neighborhood, _generator.CellSizeMeters, _window.FramebufferSize.X, _window.FramebufferSize.Y); _window.Close(); return; }
|
||||||
|
if (Environment.GetCommandLineArgs().Contains("--graphics-report")) { _window.Close(); return; }
|
||||||
|
if (_input.Keyboards.Count > 0)
|
||||||
|
{
|
||||||
|
_keyboard = _input.Keyboards[0];
|
||||||
|
_keyboard.KeyDown += OnKeyDown;
|
||||||
|
}
|
||||||
|
if (_input.Mice.Count > 0)
|
||||||
|
{
|
||||||
|
_mouse = _input.Mice[0];
|
||||||
|
_mouse.MouseMove += OnMouseMove;
|
||||||
|
_mouse.Scroll += OnScroll;
|
||||||
|
_mouse.MouseDown += OnMouseDown;
|
||||||
|
var cursor = _mouse.Cursor;
|
||||||
|
if (_mouseCaptured && cursor.IsSupported(CursorMode.Disabled))
|
||||||
|
cursor.CursorMode = CursorMode.Disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUpdate(double deltaSeconds)
|
||||||
|
{
|
||||||
|
if (_keyboard is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_benchmark?.IsRunning == true)
|
||||||
|
{
|
||||||
|
_benchmark.Update(deltaSeconds, _camera);
|
||||||
|
UpdateNeighborhood();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_menuOpen) { _mouseDelta = Vector2.Zero; return; }
|
||||||
|
|
||||||
|
if (_mouseCaptured)
|
||||||
|
{
|
||||||
|
if (_player is null) _camera.Rotate(_mouseDelta.X, _mouseDelta.Y);
|
||||||
|
else _player.Look(_mouseDelta.X, _mouseDelta.Y);
|
||||||
|
}
|
||||||
|
_mouseDelta = Vector2.Zero;
|
||||||
|
|
||||||
|
var forward = (IsDown(Key.W) ? 1 : 0) - (IsDown(Key.S) ? 1 : 0);
|
||||||
|
var side = (IsDown(Key.D) ? 1 : 0) - (IsDown(Key.A) ? 1 : 0);
|
||||||
|
var crouch = IsDown(Key.ControlLeft) || IsDown(Key.ControlRight);
|
||||||
|
if (_player is { } player)
|
||||||
|
{
|
||||||
|
player.Update(deltaSeconds, forward, side, IsDown(Key.Space), crouch, _neighborhood);
|
||||||
|
_camera.SetPlayerPose(player.Position, player.Forward, player.Up);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var vertical = (IsDown(Key.Space) ? 1 : 0) - (crouch ? 1 : 0);
|
||||||
|
var input = _camera.Forward * forward + _camera.Right * side + _camera.Up * vertical;
|
||||||
|
_camera.Update(deltaSeconds, input, _neighborhood);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNeighborhood();
|
||||||
|
|
||||||
|
_titleTimer += deltaSeconds;
|
||||||
|
if (_titleTimer >= 0.2)
|
||||||
|
{
|
||||||
|
_titleTimer = 0;
|
||||||
|
var metersPerSecond = _player?.Speed ?? _camera.SpeedMetersPerSecond;
|
||||||
|
var lightSpeed = metersPerSecond / UniverseScale.LightSpeedMetersPerSecond;
|
||||||
|
var speed = lightSpeed >= 0.01
|
||||||
|
? $"{lightSpeed.ToString("0.##", CultureInfo.InvariantCulture)}c"
|
||||||
|
: $"{metersPerSecond.ToString("0.##", CultureInfo.InvariantCulture)} m/s";
|
||||||
|
var focus = _camera.IsFocusing ? " | focusing" : string.Empty;
|
||||||
|
var center = UniversePosition.AtCellCenter(_camera.Position.Sector, _generator.CellSizeMeters);
|
||||||
|
var nearest = _neighborhood.ActiveSystem.Planets.MinBy(p =>
|
||||||
|
(p.PositionMeters - _camera.Position.RelativeTo(center, _generator.CellSizeMeters)).Length / p.RadiusMeters);
|
||||||
|
var mode = _player is null ? "Free camera" : _player.Grounded ? "Player | grounded" : "Player | falling";
|
||||||
|
_window.Title = $"Universe | {mode} | {_neighborhood.ActiveSystem.Name} | {nearest?.Name} ({nearest?.Id}) | {_camera.Position.Sector} | {speed}{focus} | {1 / _frameTime:0} FPS | P menu | F1 mouse {(_mouseCaptured ? "locked" : "free")}{(_debugMode ? " | DEBUG" : string.Empty)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRender(double deltaSeconds)
|
||||||
|
{
|
||||||
|
_frameTime += (System.Math.Clamp(deltaSeconds, 0.001, 1) - _frameTime) * 0.08;
|
||||||
|
_benchmark?.RecordFrame(deltaSeconds);
|
||||||
|
if (_renderer is not null)
|
||||||
|
{
|
||||||
|
_renderer.DebugMode = _debugMode;
|
||||||
|
_renderer.DebugProbeEnabled = _debugMode && !_menuOpen;
|
||||||
|
}
|
||||||
|
_renderer?.Render(_camera, _neighborhood, _menuOpen ? 0 : deltaSeconds);
|
||||||
|
_renderer?.DrawDebugOverlay(1 / _frameTime);
|
||||||
|
if (_benchmark is { HasRun: true } benchmark) _renderer?.DrawBenchmarkOverlay(benchmark);
|
||||||
|
if (_menuOpen) _renderer?.DrawMenu(_menuSelection, _debugMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNeighborhood()
|
||||||
|
{
|
||||||
|
if (_camera.Position.Sector == _neighborhood.ActiveSystem.Coordinate) return;
|
||||||
|
_neighborhood = _streamer.Load(_camera.Position.Sector);
|
||||||
|
if (_renderedCoordinate == _neighborhood.ActiveSystem.Coordinate) return;
|
||||||
|
_renderedCoordinate = _neighborhood.ActiveSystem.Coordinate;
|
||||||
|
_renderer?.SetActiveSystem(_renderedCoordinate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFramebufferResize(Vector2D<int> size)
|
||||||
|
{
|
||||||
|
_renderer?.Resize(size.X, size.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsDown(Key key) => _keyboard?.IsKeyPressed(key) ?? false;
|
||||||
|
|
||||||
|
private void OnKeyDown(IKeyboard keyboard, Key key, int scanCode)
|
||||||
|
{
|
||||||
|
if (key == Key.P)
|
||||||
|
{
|
||||||
|
if (_benchmark?.IsRunning == true) _benchmark.Stop();
|
||||||
|
SetMenu(!_menuOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_menuOpen)
|
||||||
|
{
|
||||||
|
if (key == Key.Escape) SetMenu(false);
|
||||||
|
else if (key == Key.Up) _menuSelection = (_menuSelection + 5) % 6;
|
||||||
|
else if (key == Key.Down) _menuSelection = (_menuSelection + 1) % 6;
|
||||||
|
else if (key is Key.Enter or Key.KeypadEnter) ActivateMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_benchmark?.IsRunning == true && key == Key.Escape)
|
||||||
|
{
|
||||||
|
_benchmark.Stop();
|
||||||
|
SetMenu(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_benchmark?.IsRunning == true && key != Key.F1) return;
|
||||||
|
if (key == Key.Escape)
|
||||||
|
_window.Close();
|
||||||
|
else if (_player is not null && key != Key.F1) return;
|
||||||
|
else if (key == Key.Q)
|
||||||
|
_camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (TryGetPlanetHotkeyIndex(key, out var planetIndex))
|
||||||
|
{
|
||||||
|
if (planetIndex < _planetHotkeys.Count)
|
||||||
|
_camera.RequestFocus(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem), _planetHotkeys[planetIndex]);
|
||||||
|
}
|
||||||
|
else if (key == Key.F2)
|
||||||
|
_camera.RequestSurfaceView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (key == Key.F3)
|
||||||
|
_camera.RequestStarView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (key == Key.F4)
|
||||||
|
_camera.RequestRingView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (key == Key.F5)
|
||||||
|
_renderer?.ReloadShaders();
|
||||||
|
else if (key == Key.F6)
|
||||||
|
_camera.RequestOceanView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (key == Key.F7)
|
||||||
|
_camera.RequestCloudView(_neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem));
|
||||||
|
else if (key == Key.F1 && _mouse is not null)
|
||||||
|
{
|
||||||
|
_mouseCaptured = !_mouseCaptured;
|
||||||
|
_mouse.Cursor.CursorMode = _mouseCaptured ? CursorMode.Disabled : CursorMode.Normal;
|
||||||
|
_hasMousePosition = false;
|
||||||
|
_mouseDelta = Vector2.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPlanetHotkeyIndex(Key key, out int index)
|
||||||
|
{
|
||||||
|
index = key switch
|
||||||
|
{
|
||||||
|
Key.Number1 or Key.Keypad1 => 0,
|
||||||
|
Key.Number2 or Key.Keypad2 => 1,
|
||||||
|
Key.Number3 or Key.Keypad3 => 2,
|
||||||
|
Key.Number4 or Key.Keypad4 => 3,
|
||||||
|
Key.Number5 or Key.Keypad5 => 4,
|
||||||
|
Key.Number6 or Key.Keypad6 => 5,
|
||||||
|
Key.Number7 or Key.Keypad7 => 6,
|
||||||
|
Key.Number8 or Key.Keypad8 => 7,
|
||||||
|
Key.Number9 or Key.Keypad9 => 8,
|
||||||
|
_ => -1
|
||||||
|
};
|
||||||
|
return index >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMouseMove(IMouse mouse, Vector2 position)
|
||||||
|
{
|
||||||
|
if (_menuOpen)
|
||||||
|
{
|
||||||
|
var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1;
|
||||||
|
if (hit >= 0) _menuSelection = hit;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_hasMousePosition)
|
||||||
|
{
|
||||||
|
_lastMousePosition = position;
|
||||||
|
_hasMousePosition = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mouseDelta += position - _lastMousePosition;
|
||||||
|
_lastMousePosition = position;
|
||||||
|
if (_mouseDelta.LengthSquared() > 10_000)
|
||||||
|
_mouseDelta = Vector2.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScroll(IMouse mouse, ScrollWheel wheel)
|
||||||
|
{
|
||||||
|
if (!_menuOpen && _player is null) _camera.AdjustSpeed(wheel.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMouseDown(IMouse mouse, MouseButton button)
|
||||||
|
{
|
||||||
|
if (!_menuOpen || button != MouseButton.Left) return;
|
||||||
|
var position = mouse.Position;
|
||||||
|
var hit = _renderer?.MenuHit(position.X, position.Y, _window.Size.X, _window.Size.Y) ?? -1;
|
||||||
|
if (hit >= 0) { _menuSelection = hit; ActivateMenu(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetMenu(bool open)
|
||||||
|
{
|
||||||
|
if (_menuOpen == open) return;
|
||||||
|
_menuOpen = open;
|
||||||
|
if (open) { _captureBeforeMenu = _mouseCaptured; _mouseCaptured = false; _menuSelection = _player is null ? 1 : 0; }
|
||||||
|
else _mouseCaptured = _captureBeforeMenu;
|
||||||
|
if (_mouse is not null)
|
||||||
|
{
|
||||||
|
var mode = _mouseCaptured ? CursorMode.Disabled : CursorMode.Normal;
|
||||||
|
if (_mouse.Cursor.IsSupported(mode)) _mouse.Cursor.CursorMode = mode;
|
||||||
|
}
|
||||||
|
_hasMousePosition = false; _mouseDelta = Vector2.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ActivateMenu()
|
||||||
|
{
|
||||||
|
if (_menuSelection == 5) { _window.Close(); return; }
|
||||||
|
if (_menuSelection == 2) { _debugMode = !_debugMode; return; }
|
||||||
|
if (_menuSelection is 3 or 4)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StartBenchmark(unknownPlanet: _menuSelection == 4);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException error)
|
||||||
|
{
|
||||||
|
_window.Title = $"Universe | Benchmark could not start | {error.Message}";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_menuSelection == 0)
|
||||||
|
{
|
||||||
|
_player ??= new PlayerController(_camera.Position, _camera.Forward, _camera.Up, _generator.CellSizeMeters, _neighborhood);
|
||||||
|
_camera.SetPlayerPose(_player.Position, _player.Forward, _player.Up);
|
||||||
|
}
|
||||||
|
else _player = null;
|
||||||
|
SetMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartBenchmark(bool unknownPlanet)
|
||||||
|
{
|
||||||
|
var system = _homeSystem;
|
||||||
|
var planet = _homeEarth;
|
||||||
|
if (unknownPlanet)
|
||||||
|
{
|
||||||
|
var candidate = _neighborhood.NearbySystems.Append(_neighborhood.ActiveSystem)
|
||||||
|
.Where(s => s.Id.StartsWith("procedural:", StringComparison.Ordinal))
|
||||||
|
.SelectMany(s => s.Planets.Where(p => !p.IsGasPlanet && p.ParentPlanetIndex is null
|
||||||
|
&& p.RadiusMeters > _homeEarth.RadiusMeters * 1.05)
|
||||||
|
.Select(p => (System: s, Planet: p)))
|
||||||
|
.OrderByDescending(body => body.Planet.RadiusMeters)
|
||||||
|
.ThenByDescending(body => body.Planet.TerrainAmplitudeMeters)
|
||||||
|
.ThenBy(body => body.System.Coordinate.X)
|
||||||
|
.ThenBy(body => body.System.Coordinate.Y)
|
||||||
|
.ThenBy(body => body.System.Coordinate.Z)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (candidate.Planet is null)
|
||||||
|
throw new InvalidOperationException("No generated planet larger than Earth is available in the neighboring systems.");
|
||||||
|
system = candidate.System;
|
||||||
|
planet = candidate.Planet;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debugMode = false;
|
||||||
|
_player = null;
|
||||||
|
_benchmark = new(system, planet, _generator.CellSizeMeters, _camera,
|
||||||
|
_homeCameraPosition, _homeCameraForward, _homeCameraUp);
|
||||||
|
UpdateNeighborhood();
|
||||||
|
SetMenu(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClosing()
|
||||||
|
{
|
||||||
|
_renderer?.Dispose();
|
||||||
|
_renderer = null;
|
||||||
|
_input?.Dispose();
|
||||||
|
_input = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_disposed = true;
|
||||||
|
_renderer?.Dispose();
|
||||||
|
_input?.Dispose();
|
||||||
|
_window.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Universe.Core/Configuration/EngineConfiguration.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Universe.Core.World;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.Core.Configuration;
|
||||||
|
|
||||||
|
public sealed record StartupConfiguration
|
||||||
|
{
|
||||||
|
public string SystemId { get; init; } = "sol";
|
||||||
|
public string PlanetId { get; init; } = "earth";
|
||||||
|
/// <summary>Planet ids assigned to number keys 1 through 9, in key order.</summary>
|
||||||
|
public List<string> PlanetHotkeys { get; init; } = [];
|
||||||
|
public double DistanceInRadii { get; init; } = 3;
|
||||||
|
public double LongitudeDegrees { get; init; } = 20;
|
||||||
|
public double LatitudeDegrees { get; init; } = 20;
|
||||||
|
public double SpeedMetersPerSecond { get; init; } = 200_000;
|
||||||
|
public double FieldOfViewDegrees { get; init; } = 67.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record EngineConfiguration
|
||||||
|
{
|
||||||
|
public string UniverseSeed { get; init; } = "0x554E495645525345";
|
||||||
|
public double CellSizeAu { get; init; } = 100;
|
||||||
|
public string KnownSolarSystemsFile { get; init; } = "KnownSolarSystem/solar-systems.json";
|
||||||
|
public StartupConfiguration Startup { 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)
|
||||||
|
: ulong.Parse(UniverseSeed, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
public (StarSystemData System, PlanetData Planet) ResolveStartup(KnownSolarSystemCatalog catalog)
|
||||||
|
{
|
||||||
|
var system = catalog.GetById(Startup.SystemId);
|
||||||
|
var planet = system.Planets.FirstOrDefault(p => p.Id == Startup.PlanetId)
|
||||||
|
?? throw new InvalidDataException($"Startup planet '{Startup.PlanetId}' does not exist in system '{system.Id}'.");
|
||||||
|
foreach (var hotkeyPlanetId in Startup.PlanetHotkeys)
|
||||||
|
if (!system.Planets.Any(p => string.Equals(p.Id, hotkeyPlanetId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
throw new InvalidDataException($"Planet hotkey target '{hotkeyPlanetId}' does not exist in startup system '{system.Id}'.");
|
||||||
|
return (system, planet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EngineConfiguration Load(string path)
|
||||||
|
{
|
||||||
|
var settings = JsonSettings.Read<EngineConfiguration>(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.UniverseSeed)) throw new InvalidDataException("config.json: universeSeed cannot be empty.");
|
||||||
|
try { _ = settings.Seed; }
|
||||||
|
catch (Exception error) when (error is FormatException or OverflowException or ArgumentNullException)
|
||||||
|
{ throw new InvalidDataException("config.json: universeSeed must be an unsigned integer string, decimal or 0x-prefixed hexadecimal.", error); }
|
||||||
|
if (!double.IsFinite(settings.CellSizeAu) || settings.CellSizeAu <= 0 || !double.IsFinite(settings.CellSizeMeters))
|
||||||
|
throw new InvalidDataException("config.json: cellSizeAu must be finite and positive.");
|
||||||
|
var startup = settings.Startup ?? throw new InvalidDataException("config.json: startup cannot be null.");
|
||||||
|
if (string.IsNullOrWhiteSpace(startup.SystemId) || string.IsNullOrWhiteSpace(startup.PlanetId)
|
||||||
|
|| startup.PlanetHotkeys is null || startup.PlanetHotkeys.Count > 9
|
||||||
|
|| startup.PlanetHotkeys.Any(string.IsNullOrWhiteSpace)
|
||||||
|
|| !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.SpeedMetersPerSecond) || startup.SpeedMetersPerSecond < 0.1
|
||||||
|
|| startup.SpeedMetersPerSecond > UniverseScale.LightSpeedMetersPerSecond * 1_000_000
|
||||||
|
|| !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.");
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.KnownSolarSystemsFile)) throw new InvalidDataException("config.json: knownSolarSystemsFile is required.");
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Universe.Core/Configuration/JsonSettings.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Universe.Core.Configuration;
|
||||||
|
|
||||||
|
internal static class JsonSettings
|
||||||
|
{
|
||||||
|
public static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||||
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static T Read<T>(string path) where T : class
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<T>(stream, Options) ?? throw new InvalidDataException($"Empty JSON file: {path}");
|
||||||
|
}
|
||||||
|
catch (JsonException error)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException($"Invalid JSON in '{path}' at {error.Path}: {error.Message}", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/Universe.Core/Configuration/KnownSolarSystemCatalog.cs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using System.Text;
|
||||||
|
using Universe.Core.Generation;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.World;
|
||||||
|
|
||||||
|
namespace Universe.Core.Configuration;
|
||||||
|
|
||||||
|
/// <summary>Explicit, validated systems override procedural generation at their assigned sectors.</summary>
|
||||||
|
public sealed class KnownSolarSystemCatalog
|
||||||
|
{
|
||||||
|
private readonly Dictionary<SectorCoordinate, StarSystemData> _bySector = [];
|
||||||
|
private readonly Dictionary<string, StarSystemData> _byId = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, RasterImage> _colors = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, HeightMap> _heights = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly string _assetDirectory;
|
||||||
|
public IReadOnlyCollection<StarSystemData> Systems => _byId.Values;
|
||||||
|
|
||||||
|
private KnownSolarSystemCatalog(string directory) => _assetDirectory = directory;
|
||||||
|
|
||||||
|
public static KnownSolarSystemCatalog Load(string path, double cellSizeMeters = UniverseScale.DefaultCellSizeMeters)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(cellSizeMeters) || cellSizeMeters <= 0) throw new ArgumentOutOfRangeException(nameof(cellSizeMeters));
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
var document = JsonSettings.Read<SolarSystemDocument>(fullPath);
|
||||||
|
if (document.SchemaVersion != 1 || document.Systems is not { Count: > 0 })
|
||||||
|
throw new InvalidDataException("Known solar systems require schemaVersion 1 and at least one system.");
|
||||||
|
var catalog = new KnownSolarSystemCatalog(Path.GetDirectoryName(fullPath)!);
|
||||||
|
foreach (var definition in document.Systems)
|
||||||
|
{
|
||||||
|
var system = catalog.Build(definition, cellSizeMeters);
|
||||||
|
if (!catalog._byId.TryAdd(system.Id, system)) throw new InvalidDataException($"Duplicate solar system id '{system.Id}'.");
|
||||||
|
if (!catalog._bySector.TryAdd(system.Coordinate, system)) throw new InvalidDataException($"Two known systems occupy sector {system.Coordinate}.");
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(SectorCoordinate coordinate, out StarSystemData system) => _bySector.TryGetValue(coordinate, out system!);
|
||||||
|
public StarSystemData GetById(string id) => _byId.TryGetValue(id, out var system) ? system
|
||||||
|
: throw new InvalidDataException($"Startup system '{id}' does not exist in KnownSolarSystem.");
|
||||||
|
|
||||||
|
private StarSystemData Build(SolarSystemDefinition definition, double cellSize)
|
||||||
|
{
|
||||||
|
ValidateId(definition.Id);
|
||||||
|
if (string.IsNullOrWhiteSpace(definition.Name) || definition.Sector is null || definition.Stars is not { Count: >= 1 and <= 2 }
|
||||||
|
|| definition.Planets is not { Count: > 0 }) throw new InvalidDataException($"Incomplete system '{definition.Id}' (one or two stars required).");
|
||||||
|
var coordinate = new SectorCoordinate(definition.Sector.X, definition.Sector.Y, definition.Sector.Z);
|
||||||
|
var offset = Vector(definition.CenterOffsetMeters);
|
||||||
|
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var stars = new List<StarData>();
|
||||||
|
foreach (var star in definition.Stars)
|
||||||
|
{
|
||||||
|
AddId(ids, star.Id); Positive(star.RadiusMeters, "star radius"); Positive(star.LuminositySolar, "luminosity");
|
||||||
|
if (string.IsNullOrWhiteSpace(star.Name)) throw new InvalidDataException($"Missing name for '{star.Id}'.");
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
var planets = new List<PlanetData>();
|
||||||
|
foreach (var planet in definition.Planets) planets.Add(BuildPlanet(planet, definition.Id, stars, planets.Count, null, ids));
|
||||||
|
for (var i = 0; i < definition.Planets.Count; i++)
|
||||||
|
{
|
||||||
|
var parent = planets[i];
|
||||||
|
foreach (var moon in definition.Planets[i].Moons ?? throw new InvalidDataException("moons cannot be null."))
|
||||||
|
{
|
||||||
|
if (moon.Moons is { Count: > 0 }) throw new InvalidDataException("Nested satellites are not supported.");
|
||||||
|
var body = BuildPlanet(moon, definition.Id, stars, planets.Count, parent, ids);
|
||||||
|
if ((body.PositionMeters - parent.PositionMeters).Length <= body.MaximumTerrainRadiusMeters + parent.MaximumTerrainRadiusMeters)
|
||||||
|
throw new InvalidDataException($"Moon '{body.Id}' overlaps its parent.");
|
||||||
|
planets.Add(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (planets.Count > 16) throw new InvalidDataException($"'{definition.Id}' exceeds the current limit of 16 planets and moons.");
|
||||||
|
foreach (var star in stars) CheckBounds(star.PositionMeters, star.RadiusMeters, cellSize, star.Id);
|
||||||
|
foreach (var planet in planets)
|
||||||
|
{
|
||||||
|
CheckBounds(planet.PositionMeters, System.Math.Max(planet.MaximumTerrainRadiusMeters, planet.Rings?.OuterRadiusMeters ?? 0), cellSize, planet.Id);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlanetData BuildPlanet(PlanetDefinition p, string systemId, List<StarData> stars, int index, PlanetData? parent, HashSet<string> ids)
|
||||||
|
{
|
||||||
|
AddId(ids, p.Id); Positive(p.RadiusMeters, "planet radius");
|
||||||
|
var terrain = p.Terrain ?? throw new InvalidDataException($"Missing terrain for '{p.Id}'.");
|
||||||
|
var air = p.Atmosphere ?? throw new InvalidDataException($"Missing atmosphere for '{p.Id}'.");
|
||||||
|
if (string.IsNullOrWhiteSpace(p.Name) || p.Orbit is null || !Enum.IsDefined(p.SurfaceType) || !Enum.IsDefined(terrain.NoiseMask))
|
||||||
|
throw new InvalidDataException($"Invalid planet '{p.Id}'.");
|
||||||
|
var host = parent is null ? stars.FindIndex(s => s.Id == p.HostStarId) : parent.HostStarIndex;
|
||||||
|
if (!Finite(p.CloudBandFrequency, p.StormStrength) || p.CloudBandFrequency <= 0 || p.StormStrength is < 0 or > 1)
|
||||||
|
throw new InvalidDataException($"Invalid cloud bands or storm strength on '{p.Id}'.");
|
||||||
|
if (host < 0) throw new InvalidDataException($"Unknown hostStarId '{p.HostStarId}' on '{p.Id}'.");
|
||||||
|
Positive(terrain.ProceduralNoiseFrequency, "noise frequency"); Positive(terrain.HeightmapGamma, "heightmap gamma"); Positive(terrain.LandBlend, "land blend");
|
||||||
|
if (!Finite(terrain.HeightmapMinMeters, terrain.HeightmapMaxMeters, terrain.LandThreshold, p.AxialTiltDegrees, p.RotationDegrees,
|
||||||
|
terrain.ProceduralNoiseAmplitudeMeters, air.HeightMeters, air.Density)
|
||||||
|
|| terrain.HeightmapMinMeters > terrain.HeightmapMaxMeters || terrain.ProceduralNoiseAmplitudeMeters < 0
|
||||||
|
|| terrain.ProceduralNoiseFrequency > 512 || air.HeightMeters < 0 || air.Density < 0 || p.AxialTiltDegrees is < 0 or > 180)
|
||||||
|
throw new InvalidDataException($"Invalid terrain, atmosphere or orientation on '{p.Id}'.");
|
||||||
|
var heightmap = terrain.Heightmap is null ? null : LoadHeight(terrain.Heightmap);
|
||||||
|
var region = terrain.RegionalHeight is null ? null : LoadRegionalHeight(terrain.RegionalHeight);
|
||||||
|
var materials = p.Materials ?? throw new InvalidDataException($"'{p.Id}': materials cannot be null.");
|
||||||
|
if (!Finite(materials.ScaleMeters, materials.FadeDistanceMeters, materials.Strength) || materials.ScaleMeters <= 0
|
||||||
|
|| materials.FadeDistanceMeters <= 0 || materials.Strength is < 0 or > 1)
|
||||||
|
throw new InvalidDataException($"'{p.Id}': invalid material settings.");
|
||||||
|
VisualSettingsValidator.Gas(p.Gas, p.Id);
|
||||||
|
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant && (heightmap is not null || region is not null || terrain.ProceduralNoiseAmplitudeMeters != 0))
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
if (clouds is not null) VisualSettingsValidator.Clouds(clouds, p.Id, air.HeightMeters, air.Density, p.SurfaceType);
|
||||||
|
var cloudDensityMap = clouds?.DensityTexturePath is { Length: > 0 } cloudPath ? LoadContentDensityMap(cloudPath) : null;
|
||||||
|
if (p.Ocean is { } ocean)
|
||||||
|
{
|
||||||
|
VisualSettingsValidator.Ocean(ocean, p.Id);
|
||||||
|
if (p.RadiusMeters + ocean.SeaLevelMeters - ocean.DepthMeters <= 0)
|
||||||
|
throw new InvalidDataException($"'{p.Id}': ocean floor must remain outside the planet centre.");
|
||||||
|
if (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant)
|
||||||
|
throw new InvalidDataException($"'{p.Id}': an ocean requires a solid planet.");
|
||||||
|
if (ocean.Enabled && ocean.MaskFromSurfaceTexture && surfaceTexture is null)
|
||||||
|
throw new InvalidDataException($"'{p.Id}': ocean.maskFromSurfaceTexture requires surfaceTexture.");
|
||||||
|
}
|
||||||
|
var result = new PlanetData(index, p.Name, position, p.RadiusMeters, terrain.ProceduralNoiseAmplitudeMeters,
|
||||||
|
air.HeightMeters, Color(p.SurfaceColor), Color(air.Color), p.Orbit.SemiMajorAxisAu * UniverseScale.AstronomicalUnitMeters,
|
||||||
|
p.Orbit.PhaseDegrees * System.Math.PI / 180, host, p.Seed ?? StableSeed(systemId + "/" + p.Id))
|
||||||
|
{
|
||||||
|
Id = p.Id,
|
||||||
|
Description = p.Description,
|
||||||
|
ParentPlanetIndex = parent?.Index,
|
||||||
|
SurfaceType = p.SurfaceType,
|
||||||
|
Gas = p.Gas,
|
||||||
|
CloudBandFrequency = p.CloudBandFrequency,
|
||||||
|
StormStrength = p.StormStrength,
|
||||||
|
SurfaceTexture = surfaceTexture,
|
||||||
|
Materials = materials,
|
||||||
|
Ocean = p.Ocean,
|
||||||
|
Clouds = clouds,
|
||||||
|
CloudDensityMap = cloudDensityMap,
|
||||||
|
OceanMask = p.Ocean is { Enabled: true, MaskFromSurfaceTexture: true } ? OceanSurface.CreateMask(surfaceTexture!) : null,
|
||||||
|
AtmosphereDensity = air.Density,
|
||||||
|
AtmosphereComposition = air.Composition,
|
||||||
|
Orientation = new(p.AxialTiltDegrees, p.RotationDegrees),
|
||||||
|
Terrain = new()
|
||||||
|
{
|
||||||
|
HeightMap = heightmap,
|
||||||
|
RegionalHeight = region,
|
||||||
|
HeightmapMinMeters = terrain.HeightmapMinMeters,
|
||||||
|
HeightmapMaxMeters = terrain.HeightmapMaxMeters,
|
||||||
|
HeightmapGamma = terrain.HeightmapGamma,
|
||||||
|
NoiseFrequency = terrain.ProceduralNoiseFrequency,
|
||||||
|
NoiseMask = terrain.NoiseMask,
|
||||||
|
LandThreshold = terrain.LandThreshold,
|
||||||
|
LandBlend = terrain.LandBlend
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (p.Rings is { } rings)
|
||||||
|
{
|
||||||
|
Positive(rings.InnerRadiusMeters, "ring inner radius"); Positive(rings.OuterRadiusMeters, "ring outer radius");
|
||||||
|
if (rings.InnerRadiusMeters <= result.MaximumTerrainRadiusMeters || rings.OuterRadiusMeters <= rings.InnerRadiusMeters
|
||||||
|
|| !Finite(rings.Opacity, rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters) || rings.Opacity is < 0 or > 1
|
||||||
|
|| rings.GapInnerRadiusMeters < 0 || rings.GapOuterRadiusMeters < rings.GapInnerRadiusMeters)
|
||||||
|
throw new InvalidDataException($"Invalid rings on '{p.Id}'.");
|
||||||
|
result = result with
|
||||||
|
{
|
||||||
|
Rings = new(rings.InnerRadiusMeters, rings.OuterRadiusMeters, Color(rings.Color), rings.Opacity,
|
||||||
|
rings.GapInnerRadiusMeters, rings.GapOuterRadiusMeters)
|
||||||
|
{
|
||||||
|
Seed = rings.Seed ?? StableSeed(systemId + "/" + p.Id + "/rings"),
|
||||||
|
ThicknessMeters = rings.ThicknessMeters,
|
||||||
|
Appearance = rings.Appearance,
|
||||||
|
Bands = rings.Bands,
|
||||||
|
Gaps = rings.Gaps,
|
||||||
|
Rocks = rings.Rocks,
|
||||||
|
Preview = rings.Preview
|
||||||
|
}
|
||||||
|
};
|
||||||
|
VisualSettingsValidator.Rings(result.Rings, p.Id);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DVector3 OrbitPosition(OrbitDefinition orbit)
|
||||||
|
{
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string AssetPath(string relative)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(relative) || Path.IsPathRooted(relative)) throw new InvalidDataException("Asset paths must be relative to KnownSolarSystem.");
|
||||||
|
var path = Path.GetFullPath(Path.Combine(_assetDirectory, relative));
|
||||||
|
if (!path.StartsWith(_assetDirectory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("Asset paths must stay inside KnownSolarSystem.");
|
||||||
|
if (!File.Exists(path)) throw new FileNotFoundException($"KnownSolarSystem asset not found: {relative}", path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
private CloudDensityMap LoadContentDensityMap(string relative)
|
||||||
|
{
|
||||||
|
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
|
||||||
|
if (Path.IsPathRooted(relative)) throw new InvalidDataException("Cloud density texture paths must be relative to Content.");
|
||||||
|
var path = Path.GetFullPath(Path.Combine(content, relative));
|
||||||
|
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("Cloud density texture paths must remain inside Content.");
|
||||||
|
if (!File.Exists(path)) throw new FileNotFoundException($"Cloud density texture not found: {relative}", path);
|
||||||
|
return CloudDensityMap.Load(path);
|
||||||
|
}
|
||||||
|
private RasterImage LoadColor(string path)
|
||||||
|
{
|
||||||
|
var full = AssetPath(path);
|
||||||
|
if (!_colors.TryGetValue(full, out var image)) _colors.Add(full, image = RasterImage.Load(full));
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
private RegionalHeightMap LoadRegionalHeight(RegionalHeightDefinition definition)
|
||||||
|
{
|
||||||
|
if (definition.Width is < 2 or > 16384 || definition.Height is < 2 or > 16384
|
||||||
|
|| !Finite(definition.WestDegrees, definition.EastDegrees, definition.NorthDegrees, definition.SouthDegrees, definition.FadeDegrees)
|
||||||
|
|| definition.WestDegrees < -180 || definition.EastDegrees > 180 || definition.SouthDegrees < -90 || definition.NorthDegrees > 90
|
||||||
|
|| definition.EastDegrees <= definition.WestDegrees || definition.NorthDegrees <= definition.SouthDegrees || definition.FadeDegrees <= 0)
|
||||||
|
throw new InvalidDataException("Invalid regional heightmap bounds or dimensions.");
|
||||||
|
var content = Path.GetFullPath(Path.Combine(_assetDirectory, "..", "Content"));
|
||||||
|
if (string.IsNullOrWhiteSpace(definition.File) || Path.IsPathRooted(definition.File)) throw new InvalidDataException("Regional heightmap path must be relative to Content.");
|
||||||
|
var path = Path.GetFullPath(Path.Combine(content, definition.File));
|
||||||
|
if (!path.StartsWith(content.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
|
throw new InvalidDataException("Regional heightmap path must remain inside Content.");
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
if (bytes.Length != checked(definition.Width * definition.Height * 4) || !BitConverter.IsLittleEndian)
|
||||||
|
throw new InvalidDataException("Regional heightmap must be little-endian float32 with the declared dimensions.");
|
||||||
|
var values = new float[definition.Width * definition.Height];
|
||||||
|
Buffer.BlockCopy(bytes, 0, values, 0, bytes.Length);
|
||||||
|
if (values.Any(v => !float.IsFinite(v) || v < -12000 || v > 12000)) throw new InvalidDataException("Invalid regional elevation values.");
|
||||||
|
return new() { Width = definition.Width, Height = definition.Height, Meters = values, MaximumMeters = values.Max(), MinimumMeters = values.Min(),
|
||||||
|
WestDegrees = definition.WestDegrees, SouthDegrees = definition.SouthDegrees, EastDegrees = definition.EastDegrees,
|
||||||
|
NorthDegrees = definition.NorthDegrees, FadeDegrees = definition.FadeDegrees };
|
||||||
|
}
|
||||||
|
private HeightMap LoadHeight(string path)
|
||||||
|
{
|
||||||
|
var full = AssetPath(path);
|
||||||
|
if (!_heights.TryGetValue(full, out var image)) _heights.Add(full, image = HeightMap.Load(full));
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
private static ulong StableSeed(string value)
|
||||||
|
{
|
||||||
|
var hash = 14695981039346656037UL;
|
||||||
|
foreach (var b in Encoding.UTF8.GetBytes(value)) hash = unchecked((hash ^ b) * 1099511628211UL);
|
||||||
|
return DeterministicRandom.Mix(hash);
|
||||||
|
}
|
||||||
|
private static DVector3 Vector(double[] values) => values is { Length: 3 } && Finite(values)
|
||||||
|
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Position vectors require three finite numbers.");
|
||||||
|
private static Vector3 Color(float[] values) => values is { Length: 3 } && values.All(v => float.IsFinite(v) && v is >= 0 and <= 1)
|
||||||
|
? new(values[0], values[1], values[2]) : throw new InvalidDataException("Colors require three numbers in [0,1].");
|
||||||
|
private static bool Finite(params double[] values) => values.All(double.IsFinite);
|
||||||
|
private static void Positive(double value, string label)
|
||||||
|
{ if (!double.IsFinite(value) || value <= 0) throw new InvalidDataException($"{label} must be finite and positive."); }
|
||||||
|
private static void ValidateId(string id)
|
||||||
|
{ if (string.IsNullOrWhiteSpace(id) || !id.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) throw new InvalidDataException($"Invalid id '{id}'."); }
|
||||||
|
private static void AddId(HashSet<string> ids, string id)
|
||||||
|
{ ValidateId(id); if (!ids.Add(id)) throw new InvalidDataException($"Duplicate body id '{id}'."); }
|
||||||
|
private static void CheckBounds(DVector3 p, double extent, double cell, string id)
|
||||||
|
{ if (System.Math.Max(System.Math.Abs(p.X), System.Math.Max(System.Math.Abs(p.Y), System.Math.Abs(p.Z))) + extent >= cell / 2) throw new InvalidDataException($"'{id}' does not fit inside its configured sector; increase cellSizeAu."); }
|
||||||
|
}
|
||||||
123
src/Universe.Core/Configuration/SolarSystemDefinitions.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.Core.Configuration;
|
||||||
|
|
||||||
|
public sealed record SolarSystemDocument
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; init; } = 1;
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
public string Notes { get; init; } = string.Empty;
|
||||||
|
public required List<SolarSystemDefinition> Systems { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record SectorDefinition(long X, long Y, long Z);
|
||||||
|
|
||||||
|
public sealed record SolarSystemDefinition
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required SectorDefinition Sector { get; init; }
|
||||||
|
public ulong? Seed { get; init; }
|
||||||
|
public double[] CenterOffsetMeters { get; init; } = [0, 0, 0];
|
||||||
|
public required List<StarDefinition> Stars { get; init; }
|
||||||
|
public required List<PlanetDefinition> Planets { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StarDefinition
|
||||||
|
{
|
||||||
|
public StarGlowSettings Glow { get; init; } = new();
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public double[] PositionMeters { get; init; } = [0, 0, 0];
|
||||||
|
public required double RadiusMeters { get; init; }
|
||||||
|
public double LuminositySolar { get; init; } = 1;
|
||||||
|
public float[] Color { get; init; } = [1, 0.94f, 0.85f];
|
||||||
|
public ulong? Seed { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record OrbitDefinition
|
||||||
|
{
|
||||||
|
public required double SemiMajorAxisAu { get; init; }
|
||||||
|
public double Eccentricity { get; init; }
|
||||||
|
public double PhaseDegrees { get; init; }
|
||||||
|
public double InclinationDegrees { get; init; }
|
||||||
|
public double AscendingNodeDegrees { get; init; }
|
||||||
|
public double PeriapsisDegrees { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AtmosphereDefinition
|
||||||
|
{
|
||||||
|
public double HeightMeters { get; init; }
|
||||||
|
public double Density { get; init; } = 1;
|
||||||
|
public float[] Color { get; init; } = [0.13f, 0.32f, 1];
|
||||||
|
public string Composition { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record TerrainDefinition
|
||||||
|
{
|
||||||
|
public string? Heightmap { get; init; }
|
||||||
|
public RegionalHeightDefinition? RegionalHeight { get; init; }
|
||||||
|
public double HeightmapMinMeters { get; init; }
|
||||||
|
public double HeightmapMaxMeters { get; init; } = 8848;
|
||||||
|
public double HeightmapGamma { get; init; } = 1;
|
||||||
|
public double ProceduralNoiseAmplitudeMeters { get; init; }
|
||||||
|
public double ProceduralNoiseFrequency { get; init; } = 1;
|
||||||
|
public TerrainNoiseMask NoiseMask { get; init; }
|
||||||
|
public double LandThreshold { get; init; } = 0.02;
|
||||||
|
public double LandBlend { get; init; } = 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RegionalHeightDefinition
|
||||||
|
{
|
||||||
|
public required string File { get; init; }
|
||||||
|
public int Width { get; init; }
|
||||||
|
public int Height { get; init; }
|
||||||
|
public double WestDegrees { get; init; }
|
||||||
|
public double SouthDegrees { get; init; }
|
||||||
|
public double EastDegrees { get; init; }
|
||||||
|
public double NorthDegrees { get; init; }
|
||||||
|
public double FadeDegrees { get; init; } = .04;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingsDefinition
|
||||||
|
{
|
||||||
|
public ulong? Seed { get; init; }
|
||||||
|
public double ThicknessMeters { get; init; } = 40;
|
||||||
|
public RingAppearanceSettings Appearance { get; init; } = new();
|
||||||
|
public RingBandSettings[] Bands { get; init; } = [];
|
||||||
|
public RingGapSettings[] Gaps { get; init; } = [];
|
||||||
|
public RingRockSettings Rocks { get; init; } = new();
|
||||||
|
public RingPreviewSettings Preview { get; init; } = new();
|
||||||
|
public required double InnerRadiusMeters { get; init; }
|
||||||
|
public required double OuterRadiusMeters { get; init; }
|
||||||
|
public float[] Color { get; init; } = [0.65f, 0.58f, 0.43f];
|
||||||
|
public double Opacity { get; init; } = 0.75;
|
||||||
|
public double GapInnerRadiusMeters { get; init; }
|
||||||
|
public double GapOuterRadiusMeters { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlanetDefinition
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
public string HostStarId { get; init; } = "sun";
|
||||||
|
public required double RadiusMeters { get; init; }
|
||||||
|
public required OrbitDefinition Orbit { get; init; }
|
||||||
|
public ulong? Seed { get; init; }
|
||||||
|
public double AxialTiltDegrees { get; init; }
|
||||||
|
public double RotationDegrees { get; init; }
|
||||||
|
public PlanetSurfaceType SurfaceType { get; init; }
|
||||||
|
public GasVolumeSettings Gas { get; init; } = new();
|
||||||
|
public double CloudBandFrequency { get; init; } = 28;
|
||||||
|
public double StormStrength { get; init; }
|
||||||
|
public float[] SurfaceColor { get; init; } = [0.38f, 0.30f, 0.22f];
|
||||||
|
public string? SurfaceTexture { get; init; }
|
||||||
|
public TerrainMaterialSettings Materials { get; init; } = new();
|
||||||
|
public OceanSettings? Ocean { get; init; }
|
||||||
|
public CloudSettings? Clouds { get; init; }
|
||||||
|
public TerrainDefinition Terrain { get; init; } = new();
|
||||||
|
public AtmosphereDefinition Atmosphere { get; init; } = new();
|
||||||
|
public RingsDefinition? Rings { get; init; }
|
||||||
|
public List<PlanetDefinition> Moons { get; init; } = [];
|
||||||
|
}
|
||||||
135
src/Universe.Core/Configuration/VisualSettingsValidator.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using Universe.Core.Generation;
|
||||||
|
|
||||||
|
namespace Universe.Core.Configuration;
|
||||||
|
|
||||||
|
internal static class VisualSettingsValidator
|
||||||
|
{
|
||||||
|
public static void Clouds(CloudSettings c, string id, double atmosphereHeight, double atmosphereDensity, PlanetSurfaceType type)
|
||||||
|
{
|
||||||
|
Range(c.BaseAltitudeMeters, 0, 1000000, id, "clouds.baseAltitudeMeters");
|
||||||
|
Range(c.ThicknessMeters, 100, 1000000, id, "clouds.thicknessMeters");
|
||||||
|
Range(c.Coverage, 0, 1, id, "clouds.coverage"); Range(c.Density, 0, 10, id, "clouds.density");
|
||||||
|
Range(c.ShapeScaleMeters, 1000, 1000000, id, "clouds.shapeScaleMeters");
|
||||||
|
Range(c.DetailScaleMeters, 100, c.ShapeScaleMeters, id, "clouds.detailScaleMeters");
|
||||||
|
Range(c.Erosion, 0, .9, id, "clouds.erosion"); Range(c.CloudType, 0, 1, id, "clouds.cloudType");
|
||||||
|
Range(c.WindSpeedMetersPerSecond, 0, 1000, id, "clouds.windSpeedMetersPerSecond");
|
||||||
|
Range(c.WindDirectionDegrees, -360, 360, id, "clouds.windDirectionDegrees");
|
||||||
|
Range(c.ExtinctionPerMeter, .000001, .1, id, "clouds.extinctionPerMeter");
|
||||||
|
Range(c.ScatteringAlbedo, 0, 1, id, "clouds.scatteringAlbedo"); Range(c.Anisotropy, 0, .9, id, "clouds.anisotropy");
|
||||||
|
Range(c.SilverLiningStrength, 0, 3, id, "clouds.silverLiningStrength");
|
||||||
|
Range(c.AmbientStrength, 0, 3, id, "clouds.ambientStrength"); Range(c.SunlightStrength, 0, 10, id, "clouds.sunlightStrength");
|
||||||
|
Range(c.SunsetStrength, 0, 5, id, "clouds.sunsetStrength"); Range(c.ShadowStrength, 0, 1, id, "clouds.shadowStrength");
|
||||||
|
Range(c.ViewSteps, 16, 192, id, "clouds.viewSteps"); Range(c.LightSteps, 2, 16, id, "clouds.lightSteps");
|
||||||
|
Range(c.ResolutionScale, .25, 1, id, "clouds.resolutionScale");
|
||||||
|
Range(c.MaxViewDistanceMeters, 10000, 2000000, id, "clouds.maxViewDistanceMeters");
|
||||||
|
Range(c.DensityTextureStrength, 0, 1, id, "clouds.densityTextureStrength");
|
||||||
|
if (c.DensityTexturePath is not null && string.IsNullOrWhiteSpace(c.DensityTexturePath))
|
||||||
|
throw new InvalidDataException($"'{id}': clouds.densityTexturePath cannot be blank; omit it to use procedural coverage.");
|
||||||
|
if (c.Enabled && (atmosphereDensity <= 0 || c.BaseAltitudeMeters + c.ThicknessMeters > atmosphereHeight
|
||||||
|
|| type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant))
|
||||||
|
throw new InvalidDataException($"'{id}': enabled clouds require a solid planet with an atmosphere containing the entire cloud layer.");
|
||||||
|
}
|
||||||
|
public static void Ocean(OceanSettings o, string id)
|
||||||
|
{
|
||||||
|
Range(o.SeaLevelMeters, -100000, 100000, id, "ocean.seaLevelMeters");
|
||||||
|
Range(o.DepthMeters, 1, 100000, id, "ocean.depthMeters");
|
||||||
|
Range(o.WaveAmplitudeMeters, 0, 30, id, "ocean.waveAmplitudeMeters");
|
||||||
|
Range(o.WaveLengthMeters, 10, 2000, id, "ocean.waveLengthMeters");
|
||||||
|
Range(o.WindDirectionDegrees, -360, 360, id, "ocean.windDirectionDegrees");
|
||||||
|
Range(o.WaveSpeed, 0, 10, id, "ocean.waveSpeed"); Range(o.Choppiness, 0, .9, id, "ocean.choppiness");
|
||||||
|
Range(o.Roughness, .04, 1, id, "ocean.roughness"); Range(o.ReflectionStrength, 0, 3, id, "ocean.reflectionStrength");
|
||||||
|
Range(o.RefractionStrength, 0, .1, id, "ocean.refractionStrength"); Range(o.FoamStrength, 0, 3, id, "ocean.foamStrength");
|
||||||
|
Color(o.DeepColor, id); Color(o.ShallowColor, id); Color(o.Absorption, id);
|
||||||
|
}
|
||||||
|
public static void StarGlow(StarGlowSettings g, string id)
|
||||||
|
{
|
||||||
|
if (g is null) throw new InvalidDataException($"'{id}': glow cannot be null.");
|
||||||
|
Range(g.Intensity, 0, 50, id, "glow.intensity"); Range(g.ReferenceDistanceAu, .001, 1000, id, "glow.referenceDistanceAu");
|
||||||
|
Range(g.DistanceFalloff, .1, 4, id, "glow.distanceFalloff"); Range(g.ApparentRadiusMultiplier, 1, 10, id, "glow.apparentRadiusMultiplier");
|
||||||
|
Range(g.MinimumCorePixels, .1, 10, id, "glow.minimumCorePixels");
|
||||||
|
Range(g.HaloRadiusDegrees, .01, 30, id, "glow.haloRadiusDegrees"); Range(g.HaloStrength, 0, 10, id, "glow.haloStrength");
|
||||||
|
Range(g.RayCount, 2, 32, id, "glow.rayCount"); Range(g.RayLengthDegrees, .01, 45, id, "glow.rayLengthDegrees");
|
||||||
|
Range(g.RaySharpness, 1, 500, id, "glow.raySharpness"); Range(g.RayStrength, 0, 20, id, "glow.rayStrength");
|
||||||
|
Range(g.GhostStrength, 0, 5, id, "glow.ghostStrength"); Range(g.GhostSpacing, .05, 2, id, "glow.ghostSpacing");
|
||||||
|
Range(g.ChromaticAberration, 0, 1, id, "glow.chromaticAberration");
|
||||||
|
}
|
||||||
|
public static void Gas(GasVolumeSettings g, string id)
|
||||||
|
{
|
||||||
|
if (g is null) throw new InvalidDataException($"'{id}': gas settings cannot be null.");
|
||||||
|
Range(g.OuterRadiusScale, 1.0001, 1.2, id, "gas.outerRadiusScale"); Range(g.PolarRadiusScale, 0.5, 1.2, id, "gas.polarRadiusScale");
|
||||||
|
Range(g.Density, 0.1, 10000, id, "gas.density"); Range(g.ScaleHeight, 0.0001, 0.2, id, "gas.scaleHeight");
|
||||||
|
Range(g.ViewSteps, 16, 192, id, "gas.viewSteps"); Range(g.LightSteps, 2, 16, id, "gas.lightSteps");
|
||||||
|
Range(g.SampleDistribution, 1, 5, id, "gas.sampleDistribution"); Range(g.StopTransmittance, 0.0001, 0.1, id, "gas.stopTransmittance");
|
||||||
|
Range(g.ScatteringAlbedo, 0, 1, id, "gas.scatteringAlbedo"); Range(g.Anisotropy, -0.9, 0.9, id, "gas.anisotropy");
|
||||||
|
Range(g.LightExtinction, 0.01, 2, id, "gas.lightExtinction");
|
||||||
|
Range(g.SunlightStrength, 0, 50, id, "gas.sunlightStrength"); Range(g.AmbientStrength, 0, 2, id, "gas.ambientStrength");
|
||||||
|
Range(g.BandContrast, 0, 1, id, "gas.bandContrast"); Range(g.FineBandFrequency, 1, 2000, id, "gas.fineBandFrequency");
|
||||||
|
Range(g.FineBandContrast, 0, 1, id, "gas.fineBandContrast"); Range(g.CloudTurbulence, 0, 2, id, "gas.cloudTurbulence");
|
||||||
|
Range(g.DetailStrength, 0, 1, id, "gas.detailStrength"); Range(g.NoiseScale, 1, 1000, id, "gas.noiseScale"); Range(g.WindSpeed, -10, 10, id, "gas.windSpeed");
|
||||||
|
if (g.BandColor is not null) Color(g.BandColor, id); if (g.PolarColor is not null) Color(g.PolarColor, id); if (g.HazeColor is not null) Color(g.HazeColor, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Rings(PlanetRings r, string id)
|
||||||
|
{
|
||||||
|
if (r.Appearance is null || r.Rocks is null || r.Preview is null || r.Bands is null || r.Gaps is null)
|
||||||
|
throw new InvalidDataException($"'{id}': ring appearance, rocks, preview, bands and gaps cannot be null.");
|
||||||
|
Range(r.ThicknessMeters, 0.1, 100000, id, "rings.thicknessMeters");
|
||||||
|
var a = r.Appearance;
|
||||||
|
Range(a.ProfileResolution, 256, 65536, id, "rings.appearance.profileResolution"); Range(a.OpticalDepth, 0, 20, id, "rings.appearance.opticalDepth");
|
||||||
|
Range(a.FineBandCount, 1, 65536, id, "rings.appearance.fineBandCount"); Range(a.MicroBandCount, 1, 65536, id, "rings.appearance.microBandCount");
|
||||||
|
Range(a.BandContrast, 0, 1, id, "rings.appearance.bandContrast"); Range(a.SunlightStrength, 0, 50, id, "rings.appearance.sunlightStrength");
|
||||||
|
Range(a.AmbientStrength, 0, 2, id, "rings.appearance.ambientStrength"); Range(a.ForwardScattering, 0, 0.95, id, "rings.appearance.forwardScattering");
|
||||||
|
Range(a.BacklitStrength, 0, 10, id, "rings.appearance.backlitStrength"); Range(a.ShadowSoftnessMeters, 1, 10000000, id, "rings.appearance.shadowSoftnessMeters");
|
||||||
|
Range(a.DustOpacity, 0, 1, id, "rings.appearance.dustOpacity"); Color(a.OuterColor, id);
|
||||||
|
foreach (var band in r.Bands)
|
||||||
|
{
|
||||||
|
if (band is null) throw new InvalidDataException($"'{id}': null ring band.");
|
||||||
|
Interval(band.InnerRadiusMeters, band.OuterRadiusMeters, r, id); Range(band.Density, 0, 20, id, "ring band density"); Color(band.Color, id);
|
||||||
|
}
|
||||||
|
foreach (var gap in r.Gaps)
|
||||||
|
{
|
||||||
|
if (gap is null) throw new InvalidDataException($"'{id}': null ring gap.");
|
||||||
|
Interval(gap.InnerRadiusMeters, gap.OuterRadiusMeters, r, id); Range(gap.DensityMultiplier, 0, 1, id, "ring gap densityMultiplier");
|
||||||
|
}
|
||||||
|
var p = r.Rocks;
|
||||||
|
var d = p.Asteroid ?? throw new InvalidDataException($"'{id}': rocks.asteroid cannot be null.");
|
||||||
|
Range(d.BaseSubdivision, 1, 3, id, "asteroid.baseSubdivision"); Range(d.MaxSubdivision, d.BaseSubdivision, 6, id, "asteroid.maxSubdivision");
|
||||||
|
Range(d.LodStartPixels, 4, 100, id, "asteroid.lodStartPixels"); Range(d.LodPixelMultiplier, 1.2, 5, id, "asteroid.lodPixelMultiplier");
|
||||||
|
Range(d.HighDetailDistanceMeters, 1, p.MeshDistanceMeters, id, "asteroid.highDetailDistanceMeters"); Range(d.MaxHighDetailInstances, 1, 64, id, "asteroid.maxHighDetailInstances");
|
||||||
|
Range(d.Elongation, 0, .7, id, "asteroid.elongation"); Range(d.NoiseFrequency, .5, 16, id, "asteroid.noiseFrequency");
|
||||||
|
Range(d.NoiseOctaves, 1, 7, id, "asteroid.noiseOctaves"); Range(d.NoisePersistence, .1, .8, id, "asteroid.noisePersistence");
|
||||||
|
Range(d.RidgeStrength, 0, .3, id, "asteroid.ridgeStrength"); Range(d.CraterFrequency, 2, 40, id, "asteroid.craterFrequency");
|
||||||
|
Range(d.CraterStrength, 0, 2, id, "asteroid.craterStrength"); Range(d.CraterDensity, 0, 1, id, "asteroid.craterDensity");
|
||||||
|
Range(d.CraterRimHeight, 0, .5, id, "asteroid.craterRimHeight"); Range(d.SurfaceDetailStrength, 0, .03, id, "asteroid.surfaceDetailStrength");
|
||||||
|
Range(d.SurfaceDetailFrequency, 10, 1000, id, "asteroid.surfaceDetailFrequency"); Range(d.NormalStrength, 0, 1, id, "asteroid.normalStrength");
|
||||||
|
Range(p.CellSizeMeters, 1, 10000, id, "rings.rocks.cellSizeMeters"); Range(p.ParticlesPerCell, 1, 4096, id, "rings.rocks.particlesPerCell");
|
||||||
|
Range(p.StreamRadiusMeters, p.CellSizeMeters, p.CellSizeMeters * 32, id, "rings.rocks.streamRadiusMeters");
|
||||||
|
Range(p.MaximumInstances, 1, 500000, id, "rings.rocks.maximumInstances");
|
||||||
|
Range(p.MinRadiusMeters, 0.001, 1000, id, "rings.rocks.minRadiusMeters"); Range(p.MaxRadiusMeters, p.MinRadiusMeters, 1000, id, "rings.rocks.maxRadiusMeters");
|
||||||
|
Range(p.SizeExponent, 0.1, 10, id, "rings.rocks.sizeExponent"); Range(p.DensityScale, 0, 20, id, "rings.rocks.densityScale");
|
||||||
|
Range(p.MeshDistanceMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.meshDistanceMeters"); Range(p.MeshLodPixels, 1, 20, id, "rings.rocks.meshLodPixels");
|
||||||
|
Range(p.LodTransitionMeters, 1, p.StreamRadiusMeters, id, "rings.rocks.lodTransitionMeters");
|
||||||
|
Range(p.FadeStartMeters, 0, p.StreamRadiusMeters - 0.1, id, "rings.rocks.fadeStartMeters");
|
||||||
|
Range(p.ShapeIrregularity, 0, 0.8, id, "rings.rocks.shapeIrregularity"); Range(p.Roughness, 0, 1, id, "rings.rocks.roughness");
|
||||||
|
Range(p.IceStrength, 0, 5, id, "rings.rocks.iceStrength"); Range(p.SunlightStrength, 0, 50, id, "rings.rocks.sunlightStrength");
|
||||||
|
Range(p.AmbientStrength, 0, 2, id, "rings.rocks.ambientStrength"); Color(p.MinColor, id); Color(p.MaxColor, id);
|
||||||
|
var v = r.Preview;
|
||||||
|
Range(v.OverviewDistanceInOuterRadii, 1.2, 10, id, "rings.preview.overviewDistanceInOuterRadii");
|
||||||
|
Range(v.OverviewElevationDegrees, -85, 85, id, "rings.preview.overviewElevationDegrees");
|
||||||
|
Range(v.OverviewSunAngleDegrees, -180, 180, id, "rings.preview.overviewSunAngleDegrees");
|
||||||
|
Range(v.RollDegrees, -180, 180, id, "rings.preview.rollDegrees");
|
||||||
|
Range(v.RadiusFraction, 0, 1, id, "rings.preview.radiusFraction"); Range(v.HeightMeters, 0.1, p.StreamRadiusMeters, id, "rings.preview.heightMeters");
|
||||||
|
Range(v.AzimuthDegrees, -360, 360, id, "rings.preview.azimuthDegrees"); Range(v.YawOffsetDegrees, -180, 180, id, "rings.preview.yawOffsetDegrees");
|
||||||
|
Range(v.PitchDegrees, -85, 85, id, "rings.preview.pitchDegrees"); Range(v.FieldOfViewDegrees, 30, 120, id, "rings.preview.fieldOfViewDegrees");
|
||||||
|
Range(v.FlightSeconds, 1, 60, id, "rings.preview.flightSeconds"); Range(v.SpeedMetersPerSecond, 0.1, 1000000, id, "rings.preview.speedMetersPerSecond");
|
||||||
|
}
|
||||||
|
private static void Interval(double inner, double outer, PlanetRings rings, string id)
|
||||||
|
{
|
||||||
|
Range(inner, rings.InnerRadiusMeters, rings.OuterRadiusMeters, id, "ring interval innerRadiusMeters");
|
||||||
|
Range(outer, inner + 0.001, rings.OuterRadiusMeters, id, "ring interval outerRadiusMeters");
|
||||||
|
}
|
||||||
|
private static void Color(float[] color, string id)
|
||||||
|
{ if (color is not { Length: 3 } || color.Any(c => !float.IsFinite(c) || c < 0 || c > 1)) throw new InvalidDataException($"'{id}': colors require three finite values in [0,1]."); }
|
||||||
|
private static void Range(double value, double min, double max, string id, string label)
|
||||||
|
{ if (!double.IsFinite(value) || value < min || value > max) throw new InvalidDataException($"'{id}': {label} must be in [{min},{max}]."); }
|
||||||
|
}
|
||||||
35
src/Universe.Core/Generation/AsteroidDetailSettings.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
public sealed record AsteroidDetailSettings
|
||||||
|
{
|
||||||
|
public int BaseSubdivision { get; init; } = 1;
|
||||||
|
public int MaxSubdivision { get; init; } = 6;
|
||||||
|
public double LodStartPixels { get; init; } = 14;
|
||||||
|
public double LodPixelMultiplier { get; init; } = 2.3;
|
||||||
|
public double HighDetailDistanceMeters { get; init; } = 80;
|
||||||
|
public int MaxHighDetailInstances { get; init; } = 12;
|
||||||
|
public double Elongation { get; init; } = .55;
|
||||||
|
public double NoiseFrequency { get; init; } = 1.1;
|
||||||
|
public int NoiseOctaves { get; init; } = 3;
|
||||||
|
public double NoisePersistence { get; init; } = .40;
|
||||||
|
public double RidgeStrength { get; init; } = .012;
|
||||||
|
public double CraterFrequency { get; init; } = 10;
|
||||||
|
public double CraterStrength { get; init; } = .28;
|
||||||
|
public double CraterDensity { get; init; } = .72;
|
||||||
|
public double CraterRimHeight { get; init; } = .06;
|
||||||
|
public double SurfaceDetailStrength { get; init; } = .0002;
|
||||||
|
public double SurfaceDetailFrequency { get; init; } = 150;
|
||||||
|
public double NormalStrength { get; init; } = .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AsteroidLod
|
||||||
|
{
|
||||||
|
public static int Select(double projectedDiameterPixels, double distanceMeters, AsteroidDetailSettings settings)
|
||||||
|
{
|
||||||
|
var level = settings.BaseSubdivision;
|
||||||
|
var threshold = settings.LodStartPixels;
|
||||||
|
while (level < settings.MaxSubdivision && projectedDiameterPixels > threshold)
|
||||||
|
{ level++; threshold *= settings.LodPixelMultiplier; }
|
||||||
|
return distanceMeters > settings.HighDetailDistanceMeters ? System.Math.Min(level, 3) : level;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Universe.Core/Generation/CelestialBodies.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using Universe.Core.Math;
|
||||||
|
using Universe.Core.World;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
public sealed record StarData(
|
||||||
|
int Index,
|
||||||
|
string Name,
|
||||||
|
DVector3 PositionMeters,
|
||||||
|
double RadiusMeters,
|
||||||
|
double LuminositySolar,
|
||||||
|
System.Numerics.Vector3 Color,
|
||||||
|
ulong Seed)
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = $"star-{Index}";
|
||||||
|
public StarGlowSettings Glow { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlanetData(
|
||||||
|
int Index,
|
||||||
|
string Name,
|
||||||
|
DVector3 PositionMeters,
|
||||||
|
double RadiusMeters,
|
||||||
|
double TerrainAmplitudeMeters,
|
||||||
|
double AtmosphereHeightMeters,
|
||||||
|
System.Numerics.Vector3 SurfaceColor,
|
||||||
|
System.Numerics.Vector3 AtmosphereColor,
|
||||||
|
double OrbitRadiusMeters,
|
||||||
|
double OrbitPhaseRadians,
|
||||||
|
int HostStarIndex,
|
||||||
|
ulong Seed)
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = $"planet-{Index}";
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
public PlanetSurfaceType SurfaceType { get; init; }
|
||||||
|
public bool IsGasPlanet => SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant;
|
||||||
|
public GasVolumeSettings Gas { get; init; } = new();
|
||||||
|
public double CloudBandFrequency { get; init; } = 28;
|
||||||
|
public double StormStrength { get; init; }
|
||||||
|
public RasterImage? SurfaceTexture { get; init; }
|
||||||
|
public TerrainMaterialSettings Materials { get; init; } = new();
|
||||||
|
public OceanSettings? Ocean { get; init; }
|
||||||
|
public CloudSettings? Clouds { get; init; }
|
||||||
|
public CloudDensityMap? CloudDensityMap { get; init; }
|
||||||
|
public HeightMap? OceanMask { get; init; }
|
||||||
|
public PlanetTerrain Terrain { get; init; } = PlanetTerrain.Default;
|
||||||
|
public PlanetOrientation Orientation { get; init; } = PlanetOrientation.Identity;
|
||||||
|
public PlanetRings? Rings { get; init; }
|
||||||
|
public double AtmosphereDensity { get; init; } = 1;
|
||||||
|
public string AtmosphereComposition { get; init; } = string.Empty;
|
||||||
|
public double MaximumTerrainRadiusMeters => RadiusMeters + System.Math.Max(TerrainAmplitudeMeters * 1.2
|
||||||
|
+ System.Math.Max(Terrain.RegionalHeight?.MaximumMeters ?? 0,
|
||||||
|
Terrain.HeightMap is null ? 0 : System.Math.Max(0, System.Math.Max(Terrain.HeightmapMinMeters, Terrain.HeightmapMaxMeters))),
|
||||||
|
Ocean is { Enabled: true } water ? water.SeaLevelMeters + water.WaveAmplitudeMeters * 3 : 0);
|
||||||
|
public int? ParentPlanetIndex { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record StarSystemData(
|
||||||
|
SectorCoordinate Coordinate,
|
||||||
|
ulong Seed,
|
||||||
|
IReadOnlyList<StarData> Stars,
|
||||||
|
IReadOnlyList<PlanetData> Planets)
|
||||||
|
{
|
||||||
|
public string Id { get; init; } = $"procedural:{Coordinate.X},{Coordinate.Y},{Coordinate.Z}";
|
||||||
|
public string Name { get; init; } = "Proceduriellt solsystem";
|
||||||
|
}
|
||||||
17
src/Universe.Core/Generation/CloudDensityMap.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using StbImageSharp;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
/// <summary>Single-channel equirectangular cloud coverage map; white means cloudy, black means clear.</summary>
|
||||||
|
public sealed record CloudDensityMap(int Width, int Height, byte[] Samples, string? SourcePath = null)
|
||||||
|
{
|
||||||
|
public static CloudDensityMap Load(string path)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
var image = ImageResult.FromStream(stream, ColorComponents.Grey);
|
||||||
|
if (image.Width < 2 || image.Height < 2 || image.Width > 16384 || image.Height > 16384
|
||||||
|
|| image.Data.Length != checked(image.Width * image.Height))
|
||||||
|
throw new InvalidDataException($"Invalid cloud density map dimensions in '{path}': {image.Width}x{image.Height}.");
|
||||||
|
return new(image.Width, image.Height, image.Data, Path.GetFullPath(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Universe.Core/Generation/CloudNoise.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
/// <summary>Periodic fractal value/Worley volumes, generated once and sampled cheaply by the GPU.</summary>
|
||||||
|
public static class CloudNoise
|
||||||
|
{
|
||||||
|
public static byte[] Build(int size, bool detail)
|
||||||
|
{
|
||||||
|
if (size is < 4 or > 128) throw new ArgumentOutOfRangeException(nameof(size));
|
||||||
|
var data = new byte[size * size * size * 4];
|
||||||
|
Parallel.For(0, size, z =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < size; y++)
|
||||||
|
for (var x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
var px = (x + .5) / size; var py = (y + .5) / size; var pz = (z + .5) / size;
|
||||||
|
var a = Cellular(px, py, pz, 4); var b = Cellular(px, py, pz, 8); var c = Cellular(px, py, pz, 16);
|
||||||
|
var perlin = Value(px, py, pz, 4) * .55 + Value(px, py, pz, 8) * .25
|
||||||
|
+ Value(px, py, pz, 16) * .13 + Value(px, py, pz, 32) * .07;
|
||||||
|
var offset = ((z * size + y) * size + x) * 4;
|
||||||
|
static byte Pack(double v) => (byte)System.Math.Clamp(System.Math.Round(v * 255), 0, 255);
|
||||||
|
data[offset] = Pack(detail ? a : (perlin - (1 - (a * .65 + b * .25 + c * .1)) * .28) / .8);
|
||||||
|
data[offset + 1] = Pack(b); data[offset + 2] = Pack(c);
|
||||||
|
data[offset + 3] = detail ? (byte)255 : Pack(Value(px, py, pz, 16) * .65 + Value(px, py, pz, 32) * .35);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Cellular(double x, double y, double z, int period)
|
||||||
|
{
|
||||||
|
x *= period; y *= period; z *= period;
|
||||||
|
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
|
||||||
|
var nearest = 4d;
|
||||||
|
for (var dz = -1; dz <= 1; dz++)
|
||||||
|
for (var dy = -1; dy <= 1; dy++)
|
||||||
|
for (var dx = -1; dx <= 1; dx++)
|
||||||
|
{
|
||||||
|
var cx = ix + dx; var cy = iy + dy; var cz = iz + dz;
|
||||||
|
var h = Hash(cx, cy, cz, period);
|
||||||
|
var px = cx + .15 + .7 * Unit(h);
|
||||||
|
var py = cy + .15 + .7 * Unit(Mix(h + 1));
|
||||||
|
var pz = cz + .15 + .7 * Unit(Mix(h + 2));
|
||||||
|
var distance = (px - x) * (px - x) + (py - y) * (py - y) + (pz - z) * (pz - z);
|
||||||
|
nearest = System.Math.Min(nearest, distance);
|
||||||
|
}
|
||||||
|
return 1 - System.Math.Min(1, System.Math.Sqrt(nearest));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double Value(double x, double y, double z, int period)
|
||||||
|
{
|
||||||
|
x *= period; y *= period; z *= period;
|
||||||
|
var ix = (int)System.Math.Floor(x); var iy = (int)System.Math.Floor(y); var iz = (int)System.Math.Floor(z);
|
||||||
|
static double Fade(double t) => t * t * t * (t * (t * 6 - 15) + 10);
|
||||||
|
var tx = Fade(x - ix); var ty = Fade(y - iy); var tz = Fade(z - iz);
|
||||||
|
var result = 0d;
|
||||||
|
for (var dz = 0; dz < 2; dz++)
|
||||||
|
for (var dy = 0; dy < 2; dy++)
|
||||||
|
for (var dx = 0; dx < 2; dx++)
|
||||||
|
result += Unit(Hash(ix + dx, iy + dy, iz + dz, period)) * (dx == 0 ? 1 - tx : tx)
|
||||||
|
* (dy == 0 ? 1 - ty : ty) * (dz == 0 ? 1 - tz : tz);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
private static double Unit(uint h) => (h & 0xFFFFFF) / 16777215d;
|
||||||
|
private static uint Hash(int x, int y, int z, int period)
|
||||||
|
{
|
||||||
|
static uint Wrap(int v, int p) => (uint)((v % p + p) % p);
|
||||||
|
return Mix(Wrap(x, period) * 73856093u ^ Wrap(y, period) * 19349663u ^ Wrap(z, period) * 83492791u ^ 0xC10D5u);
|
||||||
|
}
|
||||||
|
private static uint Mix(uint h) { h ^= h >> 16; h *= 0x7feb352du; h ^= h >> 15; h *= 0x846ca68bu; return h ^ (h >> 16); }
|
||||||
|
}
|
||||||
77
src/Universe.Core/Generation/CloudSettings.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
public sealed record CloudSettings
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; } = true;
|
||||||
|
public ulong? Seed { get; init; }
|
||||||
|
/// <summary>Optional equirectangular grayscale weather mask, relative to Content/.</summary>
|
||||||
|
public string? DensityTexturePath { get; init; }
|
||||||
|
/// <summary>Blend strength for the optional grayscale weather mask; black is clear and white is cloudy.</summary>
|
||||||
|
public double DensityTextureStrength { get; init; } = 1;
|
||||||
|
public double BaseAltitudeMeters { get; init; } = 1800;
|
||||||
|
public double ThicknessMeters { get; init; } = 5500;
|
||||||
|
public double Coverage { get; init; } = .56;
|
||||||
|
public double Density { get; init; } = 1;
|
||||||
|
public double ShapeScaleMeters { get; init; } = 42000;
|
||||||
|
public double DetailScaleMeters { get; init; } = 6500;
|
||||||
|
public double Erosion { get; init; } = .28;
|
||||||
|
public double CloudType { get; init; } = .8;
|
||||||
|
public double WindSpeedMetersPerSecond { get; init; } = 18;
|
||||||
|
public double WindDirectionDegrees { get; init; } = 25;
|
||||||
|
public double ExtinctionPerMeter { get; init; } = .0007;
|
||||||
|
public double ScatteringAlbedo { get; init; } = .96;
|
||||||
|
public double Anisotropy { get; init; } = .62;
|
||||||
|
public double SilverLiningStrength { get; init; } = .7;
|
||||||
|
public double AmbientStrength { get; init; } = .6;
|
||||||
|
public double SunlightStrength { get; init; } = 1;
|
||||||
|
public double SunsetStrength { get; init; } = 1.4;
|
||||||
|
public double ShadowStrength { get; init; } = .7;
|
||||||
|
public int ViewSteps { get; init; } = 48;
|
||||||
|
public int LightSteps { get; init; } = 4;
|
||||||
|
public double ResolutionScale { get; init; } = .5;
|
||||||
|
public double MaxViewDistanceMeters { get; init; } = 220000;
|
||||||
|
|
||||||
|
/// <summary>Uses a separate seed stream so adding weather never moves stars or planets.</summary>
|
||||||
|
public static CloudSettings? ForAtmosphere(ulong seed, double height, double density, PlanetSurfaceType type)
|
||||||
|
{
|
||||||
|
if (height < 500 || density < .09 || type is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant) return null;
|
||||||
|
var random = new DeterministicRandom(seed ^ 0x434C4F554453UL);
|
||||||
|
var strength = System.Math.Clamp(density / 1.3, 0, 1);
|
||||||
|
var baseHeight = System.Math.Clamp(height * (.018 + random.NextDouble() * .018), 100, System.Math.Min(10000, height * .4));
|
||||||
|
return new()
|
||||||
|
{
|
||||||
|
BaseAltitudeMeters = baseHeight,
|
||||||
|
ThicknessMeters = System.Math.Min(height - baseHeight, 1500 + strength * 6000 + random.NextDouble() * 2000),
|
||||||
|
Coverage = System.Math.Clamp(.08 + strength * (.4 + random.NextDouble() * .4), .04, .94),
|
||||||
|
Density = .2 + strength * (1 + random.NextDouble() * .5),
|
||||||
|
CloudType = random.NextDouble(),
|
||||||
|
WindSpeedMetersPerSecond = 5 + random.NextDouble() * (15 + strength * 35),
|
||||||
|
WindDirectionDegrees = random.NextDouble() * 360,
|
||||||
|
ShapeScaleMeters = 26000 + random.NextDouble() * 35000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CloudLighting
|
||||||
|
{
|
||||||
|
public static Vector3 SunTransmission(double sunCosine, double altitude, double atmosphereHeight,
|
||||||
|
double radius, double atmosphereDensity, Vector3 atmosphereColor, double strength)
|
||||||
|
{
|
||||||
|
var scaleHeight = System.Math.Max(100, atmosphereHeight * .055);
|
||||||
|
var airMass = System.Math.Exp(-System.Math.Max(0, altitude) / scaleHeight)
|
||||||
|
/ System.Math.Max(.025, sunCosine + System.Math.Sqrt(sunCosine * sunCosine + 2 * scaleHeight / radius));
|
||||||
|
var optical = (float)(airMass * atmosphereDensity * strength);
|
||||||
|
return new(System.MathF.Exp(-(.022f * (.7f + atmosphereColor.X * .3f)) * optical),
|
||||||
|
System.MathF.Exp(-(.055f * (.7f + atmosphereColor.Y * .3f)) * optical),
|
||||||
|
System.MathF.Exp(-(.14f * (.7f + atmosphereColor.Z * .3f)) * optical));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DVector3 Wind(CloudSettings settings)
|
||||||
|
{
|
||||||
|
var angle = settings.WindDirectionDegrees * System.Math.PI / 180;
|
||||||
|
return new DVector3(System.Math.Cos(angle), .05, System.Math.Sin(angle)).Normalized() * settings.WindSpeedMetersPerSecond;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/Universe.Core/Generation/DeterministicRandom.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
/// <summary>SplitMix64-based generator with stable results across .NET versions.</summary>
|
||||||
|
public struct DeterministicRandom(ulong seed)
|
||||||
|
{
|
||||||
|
private ulong _state = seed;
|
||||||
|
|
||||||
|
public ulong NextUInt64()
|
||||||
|
{
|
||||||
|
var z = (_state += 0x9E3779B97F4A7C15UL);
|
||||||
|
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||||
|
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
|
||||||
|
return z ^ (z >> 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double NextDouble() => (NextUInt64() >> 11) * (1d / (1UL << 53));
|
||||||
|
public int NextInt(int inclusiveMinimum, int exclusiveMaximum) =>
|
||||||
|
inclusiveMinimum + (int)(NextUInt64() % (uint)(exclusiveMaximum - inclusiveMinimum));
|
||||||
|
|
||||||
|
public static ulong Mix(ulong value)
|
||||||
|
{
|
||||||
|
value = (value ^ (value >> 30)) * 0xBF58476D1CE4E5B9UL;
|
||||||
|
value = (value ^ (value >> 27)) * 0x94D049BB133111EBUL;
|
||||||
|
return value ^ (value >> 31);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/Universe.Core/Generation/GasAndRingSettings.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
public sealed record GasVolumeSettings
|
||||||
|
{
|
||||||
|
public double OuterRadiusScale { get; init; } = 1.015;
|
||||||
|
public double PolarRadiusScale { get; init; } = 0.95;
|
||||||
|
public double Density { get; init; } = 420;
|
||||||
|
public double ScaleHeight { get; init; } = 0.008;
|
||||||
|
public int ViewSteps { get; init; } = 96;
|
||||||
|
public int LightSteps { get; init; } = 7;
|
||||||
|
public double SampleDistribution { get; init; } = 3;
|
||||||
|
public double StopTransmittance { get; init; } = 0.003;
|
||||||
|
public double ScatteringAlbedo { get; init; } = 0.94;
|
||||||
|
public double LightExtinction { get; init; } = 0.35;
|
||||||
|
public double SunlightStrength { get; init; } = 7;
|
||||||
|
public double AmbientStrength { get; init; } = 0.025;
|
||||||
|
public double Anisotropy { get; init; } = 0.18;
|
||||||
|
public double BandContrast { get; init; } = 0.18;
|
||||||
|
public double FineBandFrequency { get; init; } = 260;
|
||||||
|
public double FineBandContrast { get; init; } = 0.06;
|
||||||
|
public double CloudTurbulence { get; init; } = 0.12;
|
||||||
|
public double DetailStrength { get; init; } = 0.025;
|
||||||
|
public double NoiseScale { get; init; } = 65;
|
||||||
|
public double WindSpeed { get; init; } = 0.012;
|
||||||
|
public float[]? BandColor { get; init; }
|
||||||
|
public float[]? PolarColor { get; init; }
|
||||||
|
public float[]? HazeColor { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingBandSettings
|
||||||
|
{
|
||||||
|
public double InnerRadiusMeters { get; init; }
|
||||||
|
public double OuterRadiusMeters { get; init; }
|
||||||
|
public double Density { get; init; } = 1;
|
||||||
|
public float[] Color { get; init; } = [0.68f, 0.65f, 0.57f];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingGapSettings
|
||||||
|
{
|
||||||
|
public double InnerRadiusMeters { get; init; }
|
||||||
|
public double OuterRadiusMeters { get; init; }
|
||||||
|
public double DensityMultiplier { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingAppearanceSettings
|
||||||
|
{
|
||||||
|
public int ProfileResolution { get; init; } = 32768;
|
||||||
|
public double OpticalDepth { get; init; } = 0.9;
|
||||||
|
public double FineBandCount { get; init; } = 1200;
|
||||||
|
public double MicroBandCount { get; init; } = 12000;
|
||||||
|
public double BandContrast { get; init; } = 0.65;
|
||||||
|
public double SunlightStrength { get; init; } = 3.4;
|
||||||
|
public double AmbientStrength { get; init; } = 0.035;
|
||||||
|
public double ForwardScattering { get; init; } = 0.65;
|
||||||
|
public double BacklitStrength { get; init; } = 0.8;
|
||||||
|
public double ShadowSoftnessMeters { get; init; } = 150000;
|
||||||
|
public double DustOpacity { get; init; } = 0.08;
|
||||||
|
public float[] OuterColor { get; init; } = [0.70f, 0.68f, 0.60f];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingRockSettings
|
||||||
|
{
|
||||||
|
public AsteroidDetailSettings Asteroid { get; init; } = new();
|
||||||
|
public bool Enabled { get; init; } = true;
|
||||||
|
public double CellSizeMeters { get; init; } = 256;
|
||||||
|
public int ParticlesPerCell { get; init; } = 256;
|
||||||
|
public double StreamRadiusMeters { get; init; } = 3500;
|
||||||
|
public int MaximumInstances { get; init; } = 100000;
|
||||||
|
public double MinRadiusMeters { get; init; } = 0.06;
|
||||||
|
public double MaxRadiusMeters { get; init; } = 4;
|
||||||
|
public double SizeExponent { get; init; } = 2.8;
|
||||||
|
public double DensityScale { get; init; } = 1;
|
||||||
|
public double MeshDistanceMeters { get; init; } = 600;
|
||||||
|
public double MeshLodPixels { get; init; } = 3.5;
|
||||||
|
public double LodTransitionMeters { get; init; } = 180;
|
||||||
|
public double FadeStartMeters { get; init; } = 2600;
|
||||||
|
public double ShapeIrregularity { get; init; } = 0.27;
|
||||||
|
public double Roughness { get; init; } = 0.7;
|
||||||
|
public double IceStrength { get; init; } = 0.5;
|
||||||
|
public double SunlightStrength { get; init; } = 4;
|
||||||
|
public double AmbientStrength { get; init; } = 0.04;
|
||||||
|
public float[] MinColor { get; init; } = [0.28f, 0.28f, 0.26f];
|
||||||
|
public float[] MaxColor { get; init; } = [0.85f, 0.84f, 0.77f];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record RingPreviewSettings
|
||||||
|
{
|
||||||
|
public double OverviewDistanceInOuterRadii { get; init; } = 1.9;
|
||||||
|
public double OverviewElevationDegrees { get; init; } = 24;
|
||||||
|
public double OverviewSunAngleDegrees { get; init; } = 55;
|
||||||
|
public double RollDegrees { get; init; } = -12;
|
||||||
|
public double RadiusFraction { get; init; } = 0.88;
|
||||||
|
public double AzimuthDegrees { get; init; } = -80;
|
||||||
|
public double HeightMeters { get; init; } = 55;
|
||||||
|
public double YawOffsetDegrees { get; init; } = 35;
|
||||||
|
public double PitchDegrees { get; init; } = -12;
|
||||||
|
public double FieldOfViewDegrees { get; init; } = 100;
|
||||||
|
public double FlightSeconds { get; init; } = 8;
|
||||||
|
public double SpeedMetersPerSecond { get; init; } = 40;
|
||||||
|
}
|
||||||
41
src/Universe.Core/Generation/IcosphereMesh.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
/// <summary>Shared vertices on a closed, seam-free geodesic sphere, subdivided uniformly.</summary>
|
||||||
|
public sealed record IcosphereMesh(float[] Vertices, uint[] Indices)
|
||||||
|
{
|
||||||
|
public static IcosphereMesh Build(int subdivisions)
|
||||||
|
{
|
||||||
|
if (subdivisions is < 0 or > 6) throw new ArgumentOutOfRangeException(nameof(subdivisions));
|
||||||
|
var t = (1 + MathF.Sqrt(5)) / 2;
|
||||||
|
var vertices = new List<Vector3>{new(-1,t,0),new(1,t,0),new(-1,-t,0),new(1,-t,0),new(0,-1,t),new(0,1,t),
|
||||||
|
new(0,-1,-t),new(0,1,-t),new(t,0,-1),new(t,0,1),new(-t,0,-1),new(-t,0,1)};
|
||||||
|
for (var i = 0; i < vertices.Count; i++) vertices[i] = Vector3.Normalize(vertices[i]);
|
||||||
|
var indices = new List<uint>{0,11,5,0,5,1,0,1,7,0,7,10,0,10,11,1,5,9,5,11,4,11,10,2,10,7,6,7,1,8,
|
||||||
|
3,9,4,3,4,2,3,2,6,3,6,8,3,8,9,4,9,5,2,4,11,6,2,10,8,6,7,9,8,1};
|
||||||
|
for (var level = 0; level < subdivisions; level++)
|
||||||
|
{
|
||||||
|
var midpoints = new Dictionary<ulong, uint>();
|
||||||
|
uint Mid(uint a, uint b)
|
||||||
|
{
|
||||||
|
var key = ((ulong)System.Math.Min(a, b) << 32) | System.Math.Max(a, b);
|
||||||
|
if (midpoints.TryGetValue(key, out var index)) return index;
|
||||||
|
index = (uint)vertices.Count; vertices.Add(Vector3.Normalize(vertices[(int)a] + vertices[(int)b]));
|
||||||
|
midpoints.Add(key, index); return index;
|
||||||
|
}
|
||||||
|
var next = new List<uint>(indices.Count * 4);
|
||||||
|
for (var i = 0; i < indices.Count; i += 3)
|
||||||
|
{
|
||||||
|
var a = indices[i]; var b = indices[i + 1]; var c = indices[i + 2];
|
||||||
|
var ab = Mid(a, b); var bc = Mid(b, c); var ca = Mid(c, a);
|
||||||
|
next.AddRange([a, ab, ca, b, bc, ab, c, ca, bc, ab, bc, ca]);
|
||||||
|
}
|
||||||
|
indices = next;
|
||||||
|
}
|
||||||
|
var packed = new float[vertices.Count * 6];
|
||||||
|
for (var i = 0; i < vertices.Count; i++)
|
||||||
|
for (var c = 0; c < 3; c++) packed[i * 6 + c] = packed[i * 6 + 3 + c] = vertices[i][c];
|
||||||
|
return new(packed, indices.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/Universe.Core/Generation/OceanSettings.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
/// <summary>Metres, seconds and linear RGB. A null ocean on a planet disables water.</summary>
|
||||||
|
public sealed record OceanSettings
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; } = true;
|
||||||
|
public bool MaskFromSurfaceTexture { get; init; } = true;
|
||||||
|
public double SeaLevelMeters { get; init; }
|
||||||
|
public double DepthMeters { get; init; } = 4000;
|
||||||
|
public double WaveAmplitudeMeters { get; init; } = 1.6;
|
||||||
|
public double WaveLengthMeters { get; init; } = 72;
|
||||||
|
public double WindDirectionDegrees { get; init; } = 35;
|
||||||
|
public double WaveSpeed { get; init; } = 1;
|
||||||
|
public double Choppiness { get; init; } = .65;
|
||||||
|
public double Roughness { get; init; } = .14;
|
||||||
|
public double ReflectionStrength { get; init; } = 1;
|
||||||
|
public double RefractionStrength { get; init; } = .018;
|
||||||
|
public double FoamStrength { get; init; } = .65;
|
||||||
|
public float[] DeepColor { get; init; } = [.006f, .045f, .075f];
|
||||||
|
public float[] ShallowColor { get; init; } = [.025f, .28f, .24f];
|
||||||
|
public float[] Absorption { get; init; } = [.12f, .045f, .022f];
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct OceanWave(DVector3 Direction, double WaveNumber, double Amplitude, double AngularFrequency);
|
||||||
|
|
||||||
|
public static class OceanSurface
|
||||||
|
{
|
||||||
|
public const int WaveCount = 12;
|
||||||
|
|
||||||
|
public static OceanWave[] Spectrum(OceanSettings settings, ulong seed)
|
||||||
|
{
|
||||||
|
var random = new DeterministicRandom(seed ^ 0x4F4345414EUL);
|
||||||
|
var waves = new OceanWave[WaveCount];
|
||||||
|
for (var i = 0; i < waves.Length; i++)
|
||||||
|
{
|
||||||
|
var angle = (settings.WindDirectionDegrees + (random.NextDouble() - .5) * 115 + i * 23) * System.Math.PI / 180;
|
||||||
|
var direction = new DVector3(System.Math.Cos(angle), .23 * System.Math.Sin(angle * 1.7 + i), System.Math.Sin(angle)).Normalized();
|
||||||
|
var wavelength = settings.WaveLengthMeters * System.Math.Pow(.63, i);
|
||||||
|
var k = System.Math.Tau / wavelength;
|
||||||
|
var amplitude = settings.WaveAmplitudeMeters * System.Math.Pow(.62, i);
|
||||||
|
waves[i] = new(direction, k, amplitude, System.Math.Sqrt(9.81 * k) * settings.WaveSpeed);
|
||||||
|
}
|
||||||
|
return waves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blue ocean pixels become a shared CPU/GPU mask; land and white ice stay dry.</summary>
|
||||||
|
public static HeightMap CreateMask(RasterImage image)
|
||||||
|
{
|
||||||
|
var width = System.Math.Min(image.Width, 2048);
|
||||||
|
var height = System.Math.Max(2, (int)System.Math.Round((double)image.Height * width / image.Width));
|
||||||
|
var values = new float[width * height];
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
float water = 0;
|
||||||
|
for (var sy = 0; sy < 2; sy++)
|
||||||
|
for (var sx = 0; sx < 2; sx++)
|
||||||
|
{
|
||||||
|
var px = System.Math.Min(image.Width - 1, (int)((x + (sx + .5) / 2) * image.Width / width));
|
||||||
|
var py = System.Math.Min(image.Height - 1, (int)((y + (sy + .5) / 2) * image.Height / height));
|
||||||
|
var offset = (py * image.Width + px) * 4;
|
||||||
|
var r = image.Rgba[offset]; var g = image.Rgba[offset + 1]; var b = image.Rgba[offset + 2];
|
||||||
|
water += b > g * 1.15 && b > r * 1.35 ? .25f : 0;
|
||||||
|
}
|
||||||
|
values[y * width + x] = water;
|
||||||
|
}
|
||||||
|
// Smooth the coast before sampling; GPU UVs have float precision while collision uses doubles.
|
||||||
|
var horizontal = new float[values.Length]; var filtered = new float[values.Length];
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
horizontal[y * width + x] = (values[y * width + (x + width - 1) % width] + 2 * values[y * width + x]
|
||||||
|
+ values[y * width + (x + 1) % width]) * .25f;
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
filtered[y * width + x] = (horizontal[System.Math.Max(0, y - 1) * width + x] + 2 * horizontal[y * width + x]
|
||||||
|
+ horizontal[System.Math.Min(height - 1, y + 1) * width + x]) * .25f;
|
||||||
|
return new(width, height, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/Universe.Core/Generation/PlanetAppearance.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Numerics;
|
||||||
|
using Universe.Core.Math;
|
||||||
|
|
||||||
|
namespace Universe.Core.Generation;
|
||||||
|
|
||||||
|
public enum PlanetSurfaceType { Rocky, Terrestrial, CloudCovered, GasGiant, IceGiant }
|
||||||
|
public enum TerrainNoiseMask { Everywhere, HeightmapLand }
|
||||||
|
|
||||||
|
public sealed record PlanetTerrain
|
||||||
|
{
|
||||||
|
public static readonly PlanetTerrain Default = new();
|
||||||
|
public HeightMap? HeightMap { get; init; }
|
||||||
|
public RegionalHeightMap? RegionalHeight { get; init; }
|
||||||
|
public double HeightmapMinMeters { get; init; }
|
||||||
|
public double HeightmapMaxMeters { get; init; }
|
||||||
|
public double HeightmapGamma { get; init; } = 1;
|
||||||
|
public double NoiseFrequency { get; init; } = 1;
|
||||||
|
public TerrainNoiseMask NoiseMask { get; init; }
|
||||||
|
public double LandThreshold { get; init; } = 0.02;
|
||||||
|
public double LandBlend { get; init; } = 0.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PlanetRings(double InnerRadiusMeters, double OuterRadiusMeters, Vector3 Color, double Opacity,
|
||||||
|
double GapInnerRadiusMeters = 0, double GapOuterRadiusMeters = 0)
|
||||||
|
{
|
||||||
|
public ulong Seed { get; init; } = 1;
|
||||||
|
public double ThicknessMeters { get; init; } = 40;
|
||||||
|
public RingAppearanceSettings Appearance { get; init; } = new();
|
||||||
|
public RingBandSettings[] Bands { get; init; } = [];
|
||||||
|
public RingGapSettings[] Gaps { get; init; } = [];
|
||||||
|
public RingRockSettings Rocks { get; init; } = new();
|
||||||
|
public RingPreviewSettings Preview { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fixed body orientation. Terrain, texture UVs, ring planes and collision use this same transform.</summary>
|
||||||
|
public sealed class PlanetOrientation
|
||||||
|
{
|
||||||
|
public static readonly PlanetOrientation Identity = new(0, 0);
|
||||||
|
private readonly double _sinTilt, _cosTilt, _sinSpin, _cosSpin;
|
||||||
|
public double AxialTiltDegrees { get; }
|
||||||
|
public double RotationDegrees { get; }
|
||||||
|
public Matrix4x4 BodyToWorld { get; }
|
||||||
|
public Matrix4x4 WorldToBody { get; }
|
||||||
|
|
||||||
|
public PlanetOrientation(double axialTiltDegrees, double rotationDegrees)
|
||||||
|
{
|
||||||
|
AxialTiltDegrees = axialTiltDegrees;
|
||||||
|
RotationDegrees = rotationDegrees;
|
||||||
|
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);
|
||||||
|
WorldToBody = Matrix4x4.Transpose(BodyToWorld);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DVector3 ToBody(DVector3 d)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DVector3 ToWorld(DVector3 d)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||