bättre ringworld

This commit is contained in:
2026-11-03 20:32:59 +01:00
parent f447fe8e51
commit 6ea397a9e5
39 changed files with 2141 additions and 54 deletions

View File

@@ -14,6 +14,7 @@
| `saturn` | Saturnus |
| `uranus` | Uranus |
| `neptune` | Neptunus |
| `ring-world` | Ring World (konstruerad värld) |
`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.
@@ -25,7 +26,7 @@ Projektets `../config.json` väljer startmålet:
"startup": {
"systemId": "sol",
"planetId": "earth",
"planetHotkeys": ["mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune"],
"planetHotkeys": ["mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune", "ring-world"],
"distanceInRadii": 3.0,
"longitudeDegrees": 20,
"latitudeDegrees": 20,
@@ -359,3 +360,48 @@ 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.
## Ring World
[ring-world.json](ring-world.json) definierar en separat beboelig ring i solsystemet. Den läses via systemets `planetFiles` i `solar-systems.json`; sökvägarna är relativa till samma katalog. Tryck **9** för översikten, **F2** för en vy över insidans land, **F6** för havet och **F7** för molnlagret. Välj sedan **P → Play** för att falla ned och gå. Startmålet kan också sättas till `ring-world` i `config.json`.
```json
"radiusMeters": 2500000,
"ringWorld": {
"diameterMeters": 5000000,
"widthMeters": 500000,
"thicknessMeters": 50000,
"wallThicknessMeters": 5000,
"gravityMetersPerSecondSquared": 9.81,
"proceduralSurface": {
"metersPerTexel": 1000,
"maxTextureSize": 16384,
"continentScaleMeters": 180000,
"biomeScaleMeters": 90000,
"mountainScaleMeters": 30000,
"oceanCoverage": 0.55
}
}
```
En svensk mil är 10 km: ringen är **50 mil bred, 500 mil i diameter och 5 mil tjock**. Diametern avser den invändiga nollnivån. `radiusMeters` ska vara halva `diameterMeters`. Bredden är den fria terrängytan mellan väggarnas insidor; väggarnas bredd tillkommer. Stommen bygger utåt från nollnivån. Berg, moln och atmosfär bygger inåt mot ringens centrum. Båda kantväggarna har alltid höjden `atmosphere.heightMeters` (Jordens befintliga 220 000 m), så det finns inget separat vägghöjdsfält att hålla synkroniserat.
Ringens axel är lokal Y. `ringWorld.proceduralSurface` genererar egna färg-, höjd- och vattenkartor från planetens `seed`. Samma seed och inställningar ger samma landskap vid varje start; ändra `seed` och starta om för en ny värld. Kontinenter, kustlinjer, berg, gröna skogar, gulaktiga torra områden, sand och snö beräknas i cylindriska koordinater med fysisk skala i meter. Kartorna omsluter ringen utan skarv och delar geografi med terräng, kollision och vegetation. Molnen använder fysisk 3D-noise. Markmaterial, växtmodeller och atmosfärens färg, täthet och sammansättning återanvänds från Jorden; jordens geografiska kartor används inte.
`metersPerTexel` anger önskad geografisk upplösning åt båda hållen. Standardringen får **15 708 × 500 texlar**, cirka 1 km per texel både runt och tvärs över ringen. Närbilder kompletteras med befintlig terräng-noise och markmaterial i meterskala. `maxTextureSize` begränsar längsta bildsidan (64–16 384); kartorna begränsas dessutom till 8 388 608 texlar. Vid större ringar eller högre önskad upplösning sänks upplösningen gemensamt åt båda hållen så att proportionerna bevaras. Använd ett lägre `maxTextureSize` om grafikkortet inte stöder 16k-texturer.
`continentScaleMeters`, `biomeScaleMeters` och `mountainScaleMeters` styr landskapets fysiska storlek oberoende av ringens bredd. `oceanCoverage` anger ungefärlig andel vatten (0–1); övergången till havsbotten är mjuk. `terrain.heightmapMaxMeters` styr maximal höjd, `terrain.proceduralNoiseAmplitudeMeters` finare höjdvariation och `ocean.depthMeters` havsdjup. Höjdintervallet måste omfatta havsnivån och ha ett maximum ovanför den. Ta bort externa `surfaceTexture`, `terrain.heightmap` och `terrain.regionalHeight` när generatorn används. Havsmasken genereras direkt och kräver inte `ocean.maskFromSurfaceTexture`. Utan `proceduralSurface` kan ringvärldar fortfarande använda egna bildkartor.
Gravitationen är ett konfigurerbart artificiellt fält som pekar radiellt utåt från ringaxeln mot den invändiga cirkeln. Spelarens uppåtriktning pekar mot ringaxeln, oberoende av terrängens lutning och avståndet till kantväggarna. Gång, hopp och hukning använder denna cylindriska referensram; marknormalen används för kollisionsavstånd och bedömning av gångbara sluttningar. Kantväggarna är kollisionshinder och drar inte spelaren i sidled. Spelaren följer ringens omlopp och axelrotation. Mitten är öppen och kan flygas igenom; svept kollision stoppar även snabba passager genom stommen. Havet har en kolliderbar botten, som på Jorden; simfysik och flytkraft ingår inte.
Spelarens position sparas i ringens lokala koordinater så att avrundningen i universumets stora koordinater inte ackumuleras under omlopp och rotation. Justering av ögonhöjd sker längs gravitationen, vilket förhindrar att upprepade markkorrigeringar flyttar en stillastående spelare i sidled på ojämn terräng. Terrängens LOD återanvänder avståndsberäkningar vid små kameraförflyttningar och använder en skärmberoende detaljnivå anpassad för den konkava ringens stora synliga yta.
Ringens atmosfär och moln använder cylindriska volymer begränsade av kanterna. Havet har animerade normalvågor, Fresnelreflektion och solljusglitter. Det första ringmaterialet använder analytisk himmelsreflektion; Jordens skärmrymdsreflektioner används inte på ringen. Det artificiella gravitationsfältet är oberoende av rotationshastigheten och beräknar inte centrifugalkraft eller Corioliseffekter.
Terräng och hav tar emot trädens kaskadskuggor, styrda av `vegetation.shadowDistancesMeters`, `shadowResolution`, `shadowSoftness` och `shadowBiasMeters`. Stommen och båda kantväggarna skymmer direkt solljus enligt sina faktiska cylindriska volymer, inklusive väggarnas ovan- och utsidor. Mark, vatten, växter och atmosfär använder samma skugggeometri i ringens lokala koordinater, så skuggorna följer solen när ringen roterar. Molnskuggorna integrerar tätheten längs solstrålen genom det invändiga molnlagret. De följer de synliga molnens seed, vind och täckning; `clouds.shadowStrength` styr styrkan (0 stänger av molnskuggorna).
Verifiera med `dotnet test tests/Universe.Tests/Universe.Tests.csproj -c Release -m:1` och `dotnet run -c Release --project src/Universe.App -- --ring-world-report`. Grafikrapporten kör de riktiga OpenGL-shadrarna och sparar översikt, insida, mark, fötter och hav i `graphics-report/ring-world/` bredvid appen. `surface-map.png` visar hela färgkartan som fyra efterföljande remsor med bevarade proportioner. Rapporten skriver ut faktisk upplösning i meter och kontrollerar även att vegetation laddas och att markdjup finns under spelaren.
`performance.txt` redovisar median och 95:e percentilen för bildrutetid, kostnad per renderingspass samt en separat mätning med spelare, trädkollisioner, omlopp och rotation aktiva. Den mäter också oavsiktlig rörelse relativt ringen; högst 5 mm tillåts. Testsviten kontrollerar dessutom 60 sekunders stillastående på ojämn mark med både frusen och rörlig ring. FPS-värdena gäller aktuell dator, upplösning och inläst testscen.
Grafikrapporten testar även solstrålar över och under båda väggkanterna, stommens in- och utsida samt det öppna centrumet direkt i produktionsshadern. Den kontrollerar molnskuggornas täckning, vindrörelse och koordinattransformation, och sparar jämförelsebilderna `tree-shadows-on/off.png` och `cloud-shadows-on/off.png`. Trädjämförelsen isolerar skuggorna på ringens ytor; molnjämförelsen använder en kontrollerad mulen himmel med samma träd och terräng.

View File

@@ -0,0 +1,164 @@
{
"id": "ring-world",
"name": "Ring World",
"hostStarId": "sun",
"seed": 110,
"description": "Konstruerad ringvärld med beboelig insida, hav och atmosfär.",
"radiusMeters": 2500000,
"axialTiltDegrees": 35,
"rotationDegrees": 0,
"orbit": {
"semiMajorAxisAu": 1.15,
"eccentricity": 0,
"phaseDegrees": 155,
"inclinationDegrees": 5
},
"surfaceType": "terrestrial",
"surfaceColor": [
0.25,
0.39,
0.2
],
"materials": {
"enabled": true,
"flatMaterial": "leafy-grass",
"slopeMaterial": "rock-ground",
"scaleMeters": 8,
"fadeDistanceMeters": 2000,
"strength": 0.8
},
"clouds": {
"enabled": true,
"densityTextureStrength": 0.0,
"baseAltitudeMeters": 2100,
"thicknessMeters": 4300,
"coverage": 0.4,
"density": 0.85,
"shapeScaleMeters": 18000,
"detailScaleMeters": 2400,
"erosion": 0.45,
"cloudType": 0.9,
"windSpeedMetersPerSecond": 18,
"windDirectionDegrees": 25,
"extinctionPerMeter": 0.0007,
"scatteringAlbedo": 0.96,
"anisotropy": 0.62,
"silverLiningStrength": 0.7,
"ambientStrength": 0.6,
"sunlightStrength": 1.0,
"sunsetStrength": 1.4,
"shadowStrength": 0.7,
"viewSteps": 48,
"lightSteps": 4,
"resolutionScale": 0.4,
"maxViewDistanceMeters": 220000
},
"ocean": {
"enabled": true,
"screenSpaceReflections": {
"enabled": false,
"reflectSky": true,
"strength": 1,
"resolutionScale": 0.5,
"maxSteps": 48,
"stridePixels": 2,
"maxDistanceMeters": 3000,
"maxSurfaceDistanceMeters": 4000,
"thicknessMeters": 1.5,
"normalBiasMeters": 0.12,
"edgeFade": 0.07
},
"maskFromSurfaceTexture": false,
"seaLevelMeters": 0,
"depthMeters": 4000,
"waveAmplitudeMeters": 0.6,
"waveLengthMeters": 36,
"windDirectionDegrees": 35,
"windSpreadDegrees": 110,
"waveSpeed": 1.0,
"choppiness": 0.4,
"rippleStrength": 0.65,
"waveGeometryDistanceMeters": 4000,
"whitecapThreshold": 0.22,
"roughness": 0.26,
"reflectionStrength": 1.0,
"refractionStrength": 0.018,
"foamStrength": 0.3,
"deepColor": [
0.008,
0.07,
0.14
],
"shallowColor": [
0.025,
0.28,
0.24
],
"absorption": [
0.12,
0.045,
0.022
]
},
"terrain": {
"heightmapMinMeters": 0,
"heightmapMaxMeters": 8000,
"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": [],
"rotationPeriodHours": 24,
"massKg": 1e+23,
"poleLongitudeDegrees": 0,
"vegetation": {
"enabled": true,
"densityMultiplier": 0.7,
"treeDistanceMeters": 2000,
"treeLodMeters": [
40,
150,
400
],
"grassDistanceMeters": 80,
"shrubDistanceMeters": 150,
"affectGroundMaterials": true,
"uploadBudgetMilliseconds": 1,
"shadowResolution": 2048,
"shadowSoftness": 1.0,
"shadowCascadeBlend": 0.2,
"shadowBiasMeters": 0.035,
"shadowDistancesMeters": [
100,
400
]
},
"ringWorld": {
"diameterMeters": 5000000,
"widthMeters": 500000,
"thicknessMeters": 50000,
"wallThicknessMeters": 5000,
"gravityMetersPerSecondSquared": 9.81,
"proceduralSurface": {
"metersPerTexel": 1000,
"maxTextureSize": 16384,
"continentScaleMeters": 180000,
"biomeScaleMeters": 90000,
"mountainScaleMeters": 30000,
"oceanCoverage": 0.55
}
}
}

View File

@@ -54,6 +54,7 @@
"massKg": 1.98847e+30
}
],
"planetFiles": ["ring-world.json"],
"planets": [
{
"id": "mercury",

View File

@@ -59,6 +59,12 @@ Välj **Debug** i P-menyn för en panel till vänster med asynkron GPU-tid för
Detta är en visuell realtidsprototyp. Planeter och månar följer elliptiska Keplerbanor och roterar om sina axlar. Terrängen använder proceduriell relief; ingen hydrologisk erosionssimulering eller atmosfär med multipel spridning beräknas.
## Ring World
Tryck **9** för Ring World, **F2** för insidans terräng och välj **P → Play** för att landa och gå. **F6** visar det invändiga havet och **F7** molnen. Ringen är 500 km bred, 5 000 km i invändig diameter och har en 50 km tjock stomme. Kantväggarna följer atmosfärens höjd. Gravitationen pekar radiellt utåt mot den invändiga cirkeln; spelarens uppåtriktning följer ringens krökning utan att luta efter terräng eller kantväggar.
Mått, gravitation, proceduriella texturer, vegetation, vatten och atmosfär finns i [KnownSolarSystem/ring-world.json](KnownSolarSystem/ring-world.json). Ringens seed genererar hav, gröna och torra biom, berg och snö med samma geografiska underlag för färg, höjd och vatten. Kartans upplösning följer omkrets och bredd med jämn detaljskala i meter. Se [konfiguration och verifiering](KnownSolarSystem/README.md#ring-world). `--ring-world-report` genererar bilder från den riktiga renderingen.
## Simulationstid
Öppna **P → DEBUG ON**. Tidsreglaget i menyn går från **0× till 100×**; knapparna 0×, 1×, 10× och 100× ger snabba val. Vänster/höger ändrar med 1×, eller 10× med Shift. **1× är standard och motsvarar verklig tid**, 0× fryser himlakroppar och vädereffekter medan kameran fortfarande kan användas. Menyn pausar simulationen; vald hastighet börjar gälla när menyn stängs. Debugpanelen visar både tidsfaktor och simulerat UTC-datum.

View File

@@ -40,7 +40,8 @@
"jupiter",
"saturn",
"uranus",
"neptune"
"neptune",
"ring-world"
],
"distanceInRadii": 3.0,
"longitudeDegrees": 20,

View File

@@ -50,7 +50,7 @@ internal sealed class AdaptivePlanetMesh : IDisposable
_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);
var altitude = _planet.IsRingWorld ? System.Math.Abs(RingWorldGeometry.Clearance(_planet, cameraLocal)) : System.Math.Abs(cameraLocal.Length - _localSurfaceRadius);
_nearSurfaceDistance = altitude < 1000 ? altitude + 20 : 0;
_innerRadius = TerrainVisibility.InnerRadius(_planet);
_horizonAngle = TerrainVisibility.HorizonAngle(cameraLocal, _innerRadius, _planet.MaximumTerrainRadiusMeters);
@@ -99,8 +99,14 @@ internal sealed class AdaptivePlanetMesh : IDisposable
tile.LastFrame = _frame;
tile.LastUsedTicks=System.Diagnostics.Stopwatch.GetTimestamp();
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)
var patchSpan = _planet.IsRingWorld
? System.Math.Max(radius * System.Math.Tau / 6, _planet.RingWorld!.WidthMeters) / (1 << key.Level)
: radius * key.Width;
var spacingPixels = patchSpan / TerrainPatchBuilder.Segments / System.Math.Max(distance, 1) * screenHeight / (2 * _tanHalfFov);
// The concave band exposes its entire far side. A five-pixel near-field target keeps
// the finest LOD underfoot without refining thousands of distant quads as densely.
var pixelTarget = TerrainVisibility.PixelTarget(distance) * (_planet.IsRingWorld ? 2 : 1);
if (spacingPixels > pixelTarget && key.Level < 19)
{
Span<TerrainPatchKey> buffer = stackalloc TerrainPatchKey[4];
var childCount = 0;
@@ -128,7 +134,39 @@ internal sealed class AdaptivePlanetMesh : IDisposable
}
}
private double Distance(TerrainPatchKey key, DVector3 camera) => TerrainVisibility.DistanceToPatch(key, camera, _localSurfaceRadius);
private double Distance(TerrainPatchKey key, DVector3 camera)
{
if (!_planet.IsRingWorld) return TerrainVisibility.DistanceToPatch(key, camera, _localSurfaceRadius);
_tiles.TryGetValue(key, out var tile);
if (tile is not null)
{
if (tile.DistanceFrame == _frame) return tile.FrameDistance;
var moved = (camera - tile.DistanceCamera).Length;
if (moved <= .5)
{
// Subtract camera travel to bias LOD toward more detail while reusing the
// expensive sampled surface point for sub-metre motion or standing still.
tile.DistanceFrame = _frame;
return tile.FrameDistance = System.Math.Max(1, tile.DistanceAtCamera - moved);
}
}
var angle = System.Math.Atan2(camera.Z, camera.X);
if (angle < 0) angle += System.Math.Tau;
var start = (key.Face + key.X / (double)(1 << key.Level)) * System.Math.Tau / 6;
var width = System.Math.Tau / 6 / (1 << key.Level);
var middle = start + width * .5;
var delta = System.Math.Atan2(System.Math.Sin(angle - middle), System.Math.Cos(angle - middle));
var nearestAngle = middle + System.Math.Clamp(delta, -width * .5, width * .5);
var y0 = (key.Y / (double)(1 << key.Level) - .5) * _planet.RingWorld!.WidthMeters;
var y1 = y0 + _planet.RingWorld.WidthMeters / (1 << key.Level);
var distance = System.Math.Max(1, (RingWorldGeometry.Point(_planet, nearestAngle, System.Math.Clamp(camera.Y, y0, y1)) - camera).Length);
if (tile is not null)
{
tile.DistanceCamera = camera; tile.DistanceAtCamera = distance;
tile.DistanceFrame = _frame; tile.FrameDistance = distance;
}
return distance;
}
private bool Needed(TerrainPatchKey key, DVector3 camera)
=> Visible(key, camera) || (_nearSurfaceDistance > 0 && Distance(key, camera) <= _nearSurfaceDistance);
@@ -144,6 +182,16 @@ internal sealed class AdaptivePlanetMesh : IDisposable
private bool Visible(TerrainPatchKey key, DVector3 camera)
{
if (_planet.IsRingWorld)
{
// Cull by the view only: the opposite side of a concave habitat is visible above the player.
if (_tiles.TryGetValue(key, out var ringTile) && ringTile.Data is { } data) return GeometryVisible(data, camera);
var center = RingWorldGeometry.PatchPoint(_planet, key);
var bound = System.Math.Sqrt(System.Math.Pow(_planet.RadiusMeters * System.Math.PI / 6 / (1 << key.Level), 2)
+ System.Math.Pow(_planet.RingWorld!.WidthMeters * .5 / (1 << key.Level), 2))
+ _planet.TerrainAmplitudeMeters * 2 + System.Math.Max(0, _planet.Terrain.HeightmapMaxMeters);
return GeometryVisible(new(center, [], []) { BoundsCenter = center, BoundsRadius = bound }, camera);
}
var radius = _planet.RadiusMeters;
var outer = _planet.MaximumTerrainRadiusMeters;
// Include the recessed skirt, but keep its uncertainty radial instead of expanding
@@ -201,5 +249,8 @@ internal sealed class AdaptivePlanetMesh : IDisposable
public int LastFrame;
public long LastUsedTicks;
public bool Retiring;
public DVector3 DistanceCamera = new(double.PositiveInfinity, 0, 0);
public double DistanceAtCamera, FrameDistance;
public int DistanceFrame = -1;
}
}

View File

@@ -0,0 +1,124 @@
using Silk.NET.OpenGL;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.Physics;
using Universe.Core.World;
namespace Universe.App.Graphics;
internal static partial class GraphicsReport
{
public static void RunRingWorld(GL gl, UniverseRenderer renderer, FlyCamera camera, UniverseNeighborhood world, int width, int height)
{
var planet = world.ActiveSystem.Planets.Single(p => p.Id == "ring-world");
var dir = Path.Combine(AppContext.BaseDirectory, "graphics-report", "ring-world"); Directory.CreateDirectory(dir);
if (planet.SurfaceTexture is { } map)
{
Console.WriteLine($"Ring World surface: {map.Width}x{map.Height}, {System.Math.Tau * planet.RadiusMeters / map.Width:F2} x {planet.RingWorld!.WidthMeters / map.Height:F2} metres/texel.");
// Four consecutive strips show the full circumference without distorting physical proportions.
const int strips = 4, gap = 6;
var scale = System.Math.Min(1, 1300d * strips / map.Width);
var mapWidth = (int)(map.Width * scale / strips); var stripHeight = System.Math.Max(1, (int)(map.Height * scale));
var mapHeight = stripHeight * strips + gap * (strips - 1);
var preview = new float[mapWidth * mapHeight * 4];
for (var strip = 0; strip < strips; strip++) for (var y = 0; y < stripHeight; y++) for (var x = 0; x < mapWidth; x++)
{
var sx = System.Math.Min(map.Width - 1, (int)((strip + (x + .5) / mapWidth) * map.Width / strips));
var sy = System.Math.Min(map.Height - 1, (int)((y + .5) * map.Height / stripHeight));
var source = (sy * map.Width + sx) * 4;
var target = ((mapHeight - 1 - strip * (stripHeight + gap) - y) * mapWidth + x) * 4;
for (var c = 0; c < 3; c++) preview[target + c] = map.Rgba[source + c] / 255f;
}
WritePng(Path.Combine(dir, "surface-map.png"), mapWidth, mapHeight, preview, false);
}
var body = UniversePosition.AtCellCenter(world.ActiveSystem.Coordinate, camera.CellSizeMeters).Add(planet.PositionMeters, camera.CellSizeMeters);
renderer.SimulationTimeSeconds = 0;
void Pose(DVector3 local, DVector3 forward, DVector3 up) => camera.SetPlayerPose(body.Add(planet.Orientation.ToWorld(local), camera.CellSizeMeters),
planet.Orientation.ToWorld(forward), planet.Orientation.ToWorld(up), true);
void Capture(string name, double seconds = 12)
{
var watch = System.Diagnostics.Stopwatch.StartNew(); var frames = 0; var stable = 0;
do
{
renderer.Render(camera, world, 1d / 60); gl.Finish(); frames++;
stable = renderer.TerrainPendingTiles == 0 && renderer.TerrainUploads == 0 ? stable + 1 : 0;
} while (watch.Elapsed.TotalSeconds < seconds && (stable < 8 || frames < 40));
if (gl.GetError() != GLEnum.NoError) throw new InvalidOperationException("Ring World OpenGL error.");
var pixels = Read(gl, width, height);
if (pixels.Any(p => !float.IsFinite(p))) throw new InvalidOperationException("Non-finite Ring World output.");
WritePng(Path.Combine(dir, name + ".png"), width, height, pixels, false);
Console.WriteLine($"Ring World {name}: {frames} frames, {watch.Elapsed.TotalSeconds:F2}s, {renderer.TerrainTiles} terrain tiles, LOD {renderer.TerrainDeepestLevel}, vegetation {renderer.Vegetation.Instances}.");
}
var overview = new DVector3(1, 1.35, .65).Normalized() * planet.RadiusMeters * 3;
Pose(overview, -overview.Normalized(), new(0, 1, 0)); Capture("overview");
RunRingWorldShadowFixtures(gl, renderer, planet, dir);
var local = FlyCamera.RingInteriorPoint(world.ActiveSystem, planet);
var up = -RingWorldGeometry.Radial(local); var tangent = DVector3.Cross(new(0, 1, 0), up);
Pose(local, (tangent + up * .1).Normalized(), up); Capture("interior");
var ground = RingWorldGeometry.Point(planet, System.Math.Atan2(local.Z, local.X), local.Y, 1.7);
Pose(ground, (tangent + up * .06).Normalized(), up); Capture("ground", 35);
var samples = new List<double>();
for (var i = 0; i < 120; i++)
{
var timer = System.Diagnostics.Stopwatch.StartNew();
renderer.Render(camera, world, 1d / 60); gl.Finish();
samples.Add(timer.Elapsed.TotalMilliseconds);
}
samples.Sort();
var measurements = new List<string> { $"Ground {width}x{height}: median {samples[60]:F2} ms ({1000 / samples[60]:F1} FPS), p95 {samples[114]:F2} ms; {renderer.TerrainTiles} terrain tiles." };
renderer.ProfileFrame = true;
var passes = new Dictionary<string, double>();
for (var i = 0; i < 12; i++)
{
renderer.Render(camera, world, 1d / 60);
foreach (var pass in renderer.FrameTimings) passes[pass.Key] = passes.GetValueOrDefault(pass.Key) + pass.Value / 12;
}
renderer.ProfileFrame = false;
measurements.AddRange(passes.OrderByDescending(p => p.Value).Select(p => $"{p.Key}: {p.Value:F3} ms"));
// Include the real controller, tree collision, orbital translation and body rotation.
// Preserve the report's starting lighting/pose while advancing the actual orbit by its delta.
var player = new PlayerController(camera.Position, camera.Forward, camera.Up, camera.CellSizeMeters, world)
{ TrunksProvider = renderer.Vegetation.Trunks };
for (var i = 0; i < 60; i++) player.Update(1d / 60, 0, 0, false, false, world);
var anchored = planet.Orientation.ToBody(player.Position.RelativeTo(body, camera.CellSizeMeters));
var current = world; var maximumDrift = 0d;
samples.Clear();
for (var i = 1; i <= 180; i++)
{
var timer = System.Diagnostics.Stopwatch.StartNew(); var seconds = i / 60d;
var moved = planet with
{
PositionMeters = planet.PositionMeters + (planet.Orbit is { } orbit ? orbit.Position(seconds) - orbit.Position(0) : DVector3.Zero),
Orientation = new(planet.Orientation.AxialTiltDegrees,
planet.Orientation.RotationDegrees - 360 * seconds / (planet.Rotation?.PeriodSeconds ?? 86400), planet.Orientation.PoleLongitudeDegrees)
};
var planets = world.ActiveSystem.Planets.Select(p => p.Id == planet.Id ? moved : p).ToArray();
var next = world with { ActiveSystem = world.ActiveSystem with { Planets = planets } };
player.ApplyCelestialMotion(current, next); player.Update(1d / 60, 0, 0, false, false, next);
camera.SetPlayerPose(player.Position, player.Forward, player.Up);
renderer.Render(camera, next, 1d / 60); gl.Finish();
if (i > 60) samples.Add(timer.Elapsed.TotalMilliseconds);
var movingBody = UniversePosition.AtCellCenter(next.ActiveSystem.Coordinate, camera.CellSizeMeters).Add(moved.PositionMeters, camera.CellSizeMeters);
maximumDrift = System.Math.Max(maximumDrift, (moved.Orientation.ToBody(player.Position.RelativeTo(movingBody, camera.CellSizeMeters)) - anchored).Length);
current = next;
}
samples.Sort();
measurements.Add($"Playing on moving ring: median {samples[60]:F2} ms ({1000 / samples[60]:F1} FPS), p95 {samples[114]:F2} ms; idle drift {maximumDrift:F6} m.");
if (maximumDrift > .005) throw new InvalidOperationException("Player drifts relative to the moving Ring World.");
foreach (var line in measurements) Console.WriteLine(line);
File.WriteAllLines(Path.Combine(dir, "performance.txt"), measurements);
if (renderer.Vegetation.Stream?.InstanceCount is not > 0) throw new InvalidOperationException("Ring World vegetation did not stream.");
RunRingWorldShadowViews(gl, renderer, camera, world, planet, ground, width, height, dir);
Pose(ground, (tangent * .2 - up).Normalized(), tangent); Capture("feet", 8);
var depth = new float[width * height]; gl.BindTexture(GLEnum.Texture2D, renderer.SceneDepthTexture);
unsafe { fixed (float* p = depth) gl.GetTexImage(GLEnum.Texture2D, 0, PixelFormat.DepthComponent, PixelType.Float, p); }
var threshold = System.Math.Log2(101) / System.Math.Log2(camera.CellSizeMeters * 4.5 + 1);
var covered = depth.Count(d => float.IsFinite(d) && d < threshold) / (double)depth.Length;
Console.WriteLine($"Ring World ground depth coverage within 100m: {covered:P2}.");
if (covered < .98) throw new InvalidOperationException("Ring World ground is missing beneath the player.");
var water = FlyCamera.RingInteriorPoint(world.ActiveSystem, planet, 1); up = -RingWorldGeometry.Radial(water); tangent = DVector3.Cross(new(0, 1, 0), up);
Pose(water, (tangent - up * .15).Normalized(), up); Capture("ocean");
Console.WriteLine("Ring World graphics report PASS: " + dir);
}
}

View File

@@ -0,0 +1,134 @@
using System.Numerics;
using Silk.NET.OpenGL;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Graphics;
internal static partial class GraphicsReport
{
private static void RunRingWorldShadowFixtures(GL gl, UniverseRenderer renderer, PlanetData planet, string directory)
{
const int size = 128;
using var target = new RenderTarget(gl, size, size, fullPrecision: true);
using var quad = new GpuMesh(gl, 3, [-1, -1, 0, 3, -1, 0, -1, 3, 0]);
using var shader = ShaderProgram.Create(gl, ShaderSources.Load("fullscreen.vert"),
"#version 330 core\n" + ShaderSources.Load("ring-world-shadow") + """
in vec2 vUv;
uniform vec3 uFixtureOrigin;
uniform vec3 uFixtureLight;
uniform float uFixtureExtent;
out vec4 fragColor;
void main() {
vec3 p=uFixtureOrigin+vec3(0,(vUv.x-.5)*uFixtureExtent,(vUv.y-.5)*uFixtureExtent);
if(uFixtureExtent>0.0)p.x=sqrt(max(0.0,uFixtureOrigin.x*uFixtureOrigin.x-p.z*p.z));
vec3 ld=normalize(uFixtureLight);
vec3 world=transpose(mat3(uRingWorldToBody))*(p-uRingCameraBody)*uRingRadius;
vec3 light=transpose(mat3(uRingWorldToBody))*ld;
fragColor=vec4(ringHullVisibility(p,ld),ringCloudTransmission(p,ld),ringEnvironmentShadowWorld(world,light),1);
}
""");
float[] Draw(PlanetData body, Vector3 origin, Vector3 light, float extent = 0, float time = 0)
{
target.Bind(); gl.Disable(GLEnum.DepthTest); gl.Disable(GLEnum.Blend); shader.Use();
renderer.BindRingShadowSettings(shader, body, new DVector3(origin.X,origin.Y,origin.Z) * body.RadiusMeters);
shader.Set("uFixtureOrigin", origin); shader.Set("uFixtureLight", light); shader.Set("uFixtureExtent", extent);
shader.Set("uRingTime", time); quad.Draw(); gl.Finish();
var pixels = Read(gl, size, size);
if (pixels.Any(p => !float.IsFinite(p)) || gl.GetError() != GLEnum.NoError)
throw new InvalidOperationException("Non-finite or invalid Ring World shadow shader output.");
return pixels;
}
var clear = planet with { Clouds = planet.Clouds! with { Coverage = 0 }, CloudDensityMap = null };
var cases = new (Vector3 Origin, Vector3 Light, float Expected, string Name)[]
{
(new(.9996f,0,0),new(-1,.8f,0),1,"sun above positive rim"),
(new(.9996f,0,0),new(-1,-.8f,0),1,"sun above negative rim"),
(new(.9996f,0,0),new(-.2f,1,0),0,"positive wall blocks low sun"),
(new(.9996f,0,0),new(-.2f,-1,0),0,"negative wall blocks low sun"),
(new(.9996f,0,0),new(-1,0,0),0,"opposite deck blocks sun"),
(new(.88f,0,0),new(0,1,0),1,"ray above rim"),
(Vector3.Zero,new(0,1,0),1,"open axis"),
(new(.95f,.09999f,0),new(0,1,0),0,"inner wall face"),
(new(.95f,.10201f,0),new(0,1,0),1,"outer wall face"),
(new(1.02001f,0,0),new(1,0,0),1,"outer hull facing sun"),
(new(1.02001f,0,0),new(-1,0,0),0,"outer hull facing away")
};
foreach (var test in cases)
{
var pixels = Draw(clear, test.Origin, test.Light);
if (System.Math.Abs(pixels[0] - test.Expected) > .001 || System.Math.Abs(pixels[2] - test.Expected) > .001)
throw new InvalidOperationException($"Ring World shadow mismatch ({test.Name}): {pixels[0]}/{pixels[2]}, expected {test.Expected}.");
}
var cloudy = clear with { Clouds = clear.Clouds! with { Coverage = 1, ShadowStrength = 1, Density = 1 } };
var overcast = Draw(cloudy, new(.9996f,0,0), new(-1,.8f,0));
if (overcast[1] >= .6 || overcast[2] >= .6)
throw new InvalidOperationException($"Overcast ring clouds did not shadow the ground: {overcast[1]}.");
var disabled = Draw(cloudy with { Clouds = cloudy.Clouds! with { ShadowStrength = 0 } }, new(.9996f,0,0), new(-1,.8f,0));
if (disabled[1] < .999) throw new InvalidOperationException("Ring cloud shadow strength zero still casts shadows.");
var weather = Draw(planet, new(.9996f,0,0), new(-1,.8f,0), .12f);
var later = Draw(planet, new(.9996f,0,0), new(-1,.8f,0), .12f, 900);
double windDelta = 0; var low = 1f; var high = 0f; var transformError = 0f;
for (var i = 1; i < weather.Length; i += 4)
{
windDelta += System.Math.Abs(weather[i] - later[i]) / (size * size);
low = System.Math.Min(low, weather[i]); high = System.Math.Max(high, weather[i]);
transformError = System.Math.Max(transformError, System.Math.Abs(weather[i - 1] * weather[i] - weather[i + 1]));
}
// Ray fixtures above are binary/exact. Allow one percent for filtered density:
// camera-relative float roundtrips can cross a hardware interpolation step.
if (transformError > .01) throw new InvalidOperationException($"Body/world cloud shadow error: {transformError}.");
if (high - low < .1 || windDelta < .01) throw new InvalidOperationException("Ring cloud shadows do not vary spatially or follow wind.");
WritePng(Path.Combine(directory, "shadow-field.png"), size, size, weather, false);
Console.WriteLine($"Ring shadow GPU fixtures PASS: {cases.Length} hull/wall rays, overcast transmission {overcast[1]:F3}, cloud range {low:F3}–{high:F3}, wind delta {windDelta:F3}, transform error {transformError:F4}.");
}
private static void RunRingWorldShadowViews(GL gl, UniverseRenderer renderer, FlyCamera camera, UniverseNeighborhood world,
PlanetData planet, DVector3 ground, int width, int height, string directory)
{
var trees = renderer.Vegetation.Stream!.Tiles.SelectMany(t => t.Instances)
.Where(i => renderer.Vegetation.Catalog.Plants[i.PlantIndex].Type == VegetationType.Tree)
.OrderBy(i => (i.BodyPosition - ground).LengthSquared).ToArray();
if (trees.Length == 0) throw new InvalidOperationException("Missing tree shadow test casters.");
var tree = trees[0].BodyPosition; var up = -RingWorldGeometry.Radial(tree);
var tangent = DVector3.Cross(new(0,1,0), up).Normalized();
var local = tree + up * 18 + tangent * 24;
var body = UniversePosition.AtCellCenter(world.ActiveSystem.Coordinate, camera.CellSizeMeters).Add(planet.PositionMeters, camera.CellSizeMeters);
camera.SetPlayerPose(body.Add(planet.Orientation.ToWorld(local), camera.CellSizeMeters),
planet.Orientation.ToWorld((tree + up * 2 - local).Normalized()), planet.Orientation.ToWorld(up), true);
for (var i = 0; i < 120; i++) { renderer.Render(camera, world, 0); gl.Finish(); }
float[] Snapshot(UniverseNeighborhood scene, string name)
{
renderer.Render(camera, scene, 0); gl.Finish();
var pixels = Read(gl, width, height); WritePng(Path.Combine(directory, name + ".png"), width, height, pixels, false);
return pixels;
}
double Darkened(float[] on, float[] off)
{
double delta = 0;
for (var i = 0; i < on.Length; i += 4)
delta += System.Math.Max(0, (off[i] + off[i+1] + off[i+2] - on[i] - on[i+1] - on[i+2]) / 3) / (width * height);
return delta;
}
try
{
renderer.RingCloudShadows = false;
renderer.RingVegetationShadows = false; var noTrees = Snapshot(world, "tree-shadows-off");
renderer.RingVegetationShadows = true; var withTrees = Snapshot(world, "tree-shadows-on");
var treeDelta = Darkened(withTrees, noTrees);
var overcastPlanet = planet with { Clouds = planet.Clouds! with { Coverage = 1, ShadowStrength = 1 }, CloudDensityMap = null };
var overcast = world with { ActiveSystem = world.ActiveSystem with
{ Planets = world.ActiveSystem.Planets.Select(p => p.Id == planet.Id ? overcastPlanet : p).ToArray() } };
var noClouds = Snapshot(overcast, "cloud-shadows-off");
renderer.RingCloudShadows = true; var withClouds = Snapshot(overcast, "cloud-shadows-on");
var cloudDelta = Darkened(withClouds, noClouds);
if (treeDelta < .0001 || cloudDelta < .001 || gl.GetError() != GLEnum.NoError)
throw new InvalidOperationException($"Ring shadow image comparison failed: tree {treeDelta:G5}, cloud {cloudDelta:G5}.");
Console.WriteLine($"Ring shadow scene PASS: tree shadow darkening {treeDelta:F4}, cloud shadow darkening {cloudDelta:F4}.");
}
finally { renderer.RingVegetationShadows = true; renderer.RingCloudShadows = true; }
}
}

View File

@@ -15,6 +15,9 @@ internal sealed unsafe class PlanetTextures : IDisposable
public PlanetTextures(GL gl, PlanetData planet)
{
_gl = gl;
if (planet.RingWorld?.ProceduralSurface is not null && planet.SurfaceTexture is { } generated
&& System.Math.Max(generated.Width, generated.Height) > gl.GetInteger(GLEnum.MaxTextureSize))
throw new InvalidOperationException("Ring World texture exceeds this GPU's limit. Lower ringWorld.proceduralSurface.maxTextureSize in its JSON configuration.");
Color = gl.GenTexture();
gl.BindTexture(GLEnum.Texture2D, Color);
var image = planet.SurfaceTexture;

View File

@@ -17,7 +17,7 @@ internal static class ShaderSources
}
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", "screen-space-reflection", "vegetation-shadow", "biomes" })
foreach (var include in new[] { "noise", "planet-height", "camera-ray", "ring-optics", "rock-common", "asteroid-shape", "cloud-common", "screen-space-reflection", "vegetation-shadow", "biomes", "ring-world-shadow", "ring-world-common" })
{
var directive = $"#include \"{include}.glsl\"";
if (source.Contains(directive, StringComparison.Ordinal))

View File

@@ -0,0 +1,57 @@
#version 330 core
#include "ring-world-common.glsl"
#include "camera-ray.glsl"
in vec2 vUv;
uniform sampler2D uScene;
uniform sampler2D uDepth;
uniform float uFar;
out vec4 fragColor;
vec3 integrate(vec3 source,vec3 o,vec3 d,vec2 interval,bool clouds) {
float a=max(0.0,interval.x),b=interval.y;
if(b<=a)return source;
vec3 scatter=vec3(0);vec3 transmission=vec3(1);
// Integrate front-to-back, then composite this interval over the intervals behind it.
for(int i=0;i<48;i++) {
float x=float(i)/48.0,y=float(i+1)/48.0;
float t=a+(b-a)*(x*x+y*y)*.5,ds=(b-a)*(y*y-x*x)*uRadius;
vec3 p=o+d*t;
float height=max(0.0,(1.0-length(p.xz))*uRadius);
vec3 n=-normalize(vec3(p.x,0,p.z));
vec3 light=vec3(.14);
for(int j=0;j<2;j++) {
if(j>=uLightCount)break;
float sun=max(.03,.25+.75*dot(n,uLightDirections[j]));
float mu=dot(d,uLightDirections[j]);
light+=uLightColors[j]*uLightFlux[j]*sun*(.7+.3*mu*mu)*ringHullVisibility(p,uLightDirections[j]);
}
vec3 extinction;
vec3 tint;
if(clouds) {
float density=ringCloudDensity(p);
extinction=vec3(density*uRingCloudShape.z);
tint=vec3(.92,.96,1.0)*light;
} else {
float density=exp(-height/max(100.0,uAirHeight*uRadius*.055))*uAirDensity;
extinction=(vec3(.45,.55,.7)+uAirColor)*density*.000012;
tint=(vec3(.18,.28,.45)+uAirColor*.4)*light;
}
vec3 stepTransmission=exp(-extinction*ds);
scatter+=transmission*(1.0-stepTransmission)*tint;
transmission*=stepTransmission;
if(max(transmission.r,max(transmission.g,transmission.b))<.001)break;
}
return source*transmission+scatter;
}
void main() {
vec3 worldRay=cameraRay(vUv),d=mat3(uWorldToBody)*worldRay,o=uCameraBody;
float depth=texture(uDepth,vUv).r;
float stop=depth>=.999999?1e8:(exp2(depth*log2(uFar+1.0))-1.0)/(uRadius*max(.0001,dot(worldRay,uForward)));
vec3 color=texture(uScene,vUv).rgb;
vec4 air=shellSegments(o,d,1.0-uAirHeight,1.0,uRingSize.x,stop);
color=integrate(color,o,d,air.zw,false);color=integrate(color,o,d,air.xy,false);
if(uRingCloudEnabled!=0) {
vec4 cloud=shellSegments(o,d,1.0-uRingCloudLayer.x-uRingCloudLayer.y,1.0-uRingCloudLayer.x,uRingSize.x,stop);
color=integrate(color,o,d,cloud.zw,true);color=integrate(color,o,d,cloud.xy,true);
}
fragColor=vec4(color,1);
}

View File

@@ -0,0 +1,35 @@
#include "ring-world-shadow.glsl"
#include "vegetation-shadow.glsl"
#include "planet-height.glsl"
uniform float uAirHeight;
uniform float uAirDensity;
uniform vec3 uAirColor;
uniform vec3 uCameraBody;
uniform int uLightCount;
uniform vec3 uLightDirections[2];
uniform vec3 uLightColors[2];
uniform float uLightFlux[2];
uniform float uTime;
vec2 ringUv(vec3 p) {
return vec2(.5-atan(p.z,p.x)/6.28318530718,clamp(.5-p.y/(uRingSize.x*2.0),0.0,1.0));
}
vec2 ringUvDerivative(vec3 p,vec3 dp) {
return vec2((p.z*dp.x-p.x*dp.z)/(max(1e-12,dot(p.xz,p.xz))*6.28318530718),-dp.y/(uRingSize.x*2.0));
}
vec3 ringDirection(vec3 p) {
// Avoid negative cos(pi/2) at a rim due to float roundoff, which flips longitude by half a turn.
float latitude=clamp(p.y/(uRingSize.x*2.0),-.4999999,.4999999)*3.14159265359;
return vec3(normalize(p.xz).x*cos(latitude),sin(latitude),normalize(p.xz).y*cos(latitude));
}
vec3 ringLight(vec3 p,vec3 n,vec3 world) {
vec3 light=vec3(.09);
for(int i=0;i<2;i++) {
if(i>=uLightCount)break;
vec3 ld=uLightDirections[i];
float ndl=max(0.0,dot(n,ld));
if(ndl<=0.0)continue;
float visibility=ringEnvironmentShadow(p,ld)*vegetationShadow(world,i);
light+=uLightColors[i]*uLightFlux[i]*ndl*visibility;
}
return light;
}

View File

@@ -0,0 +1,66 @@
#version 330 core
#include "ring-world-common.glsl"
#include "camera-ray.glsl"
in vec2 vUv;
uniform float uFar;
uniform int uWaterEnabled;
uniform float uWaterLevel;
uniform vec3 uWaterDeep;
uniform vec3 uWaterShallow;
uniform vec3 uWaves;
out vec4 fragColor;
float best=1e10;
vec3 normal;
int material=0;
void candidate(float t,vec3 n,int m) {if(t>.00000001&&t<best){best=t;normal=n;material=m;}}
void tube(vec3 o,vec3 d,float radius,float y0,float y1,float signNormal,int m) {
vec2 times=cylinder(o,d,radius);
for(int i=0;i<2;i++) {
float t=i==0?times.x:times.y;
vec3 p=o+d*t;
if(p.y>=y0&&p.y<=y1)candidate(t,normalize(vec3(p.x,0,p.z))*signNormal,m);
}
}
void disk(vec3 o,vec3 d,float y,float inner,float outer,float signNormal) {
if(abs(d.y)<1e-12)return;
float t=(y-o.y)/d.y;float r=length((o+d*t).xz);
if(r>=inner&&r<=outer)candidate(t,vec3(0,signNormal,0),0);
}
void main() {
vec3 worldRay=cameraRay(vUv),d=mat3(uWorldToBody)*worldRay,o=uCameraBody;
float w=uRingSize.x,ow=w+uRingSize.z,outer=1.0+uRingSize.y,rim=1.0-uAirHeight;
tube(o,d,outer,-ow,ow,1.0,0);
tube(o,d,rim,w,ow,-1.0,0);tube(o,d,rim,-ow,-w,-1.0,0);
disk(o,d,w,rim,outer,-1.0);disk(o,d,-w,rim,outer,1.0);
disk(o,d,ow,rim,outer,1.0);disk(o,d,-ow,rim,outer,-1.0);
if(uWaterEnabled!=0)tube(o,d,1.0-uWaterLevel,-w,w,-1.0,1);
if(best>1e9)discard;
vec3 p=o+d*best;
vec3 world=worldRay*(best*uRadius);
vec3 color;
if(material==1) {
vec3 direction=ringDirection(p);float map;
float h=mappedElevation(direction,map);
float wet=uHasOceanMask!=0?oceanMaskValue(direction):float(h<uWaterLevel);
// Keep shallow generated coastlines covered too; the terrain depth test clips water on dry land.
if(wet<=.0001&&h>=uWaterLevel)discard;
float a=atan(p.z,p.x)*uRadius,across=p.y*uRadius;
float k=6.2831853/max(1.0,uWaves.y),time=uTime*uWaves.z;
vec3 tangent=normalize(vec3(-p.z,0,p.x));
float waveFilter=1.0-smoothstep(.5,3.0,max(fwidth(a*k),fwidth(across*k)));
normal=normalize(normal+(tangent*cos(a*k+time)*uWaves.x*k*.5+vec3(0,1,0)*sin(across*k*.73-time)*uWaves.x*k*.4)*waveFilter);
float fresnel=.02+.98*pow(1.0-max(0.0,dot(normal,-d)),5.0);
color=mix(uWaterShallow,uWaterDeep,smoothstep(.05,.8,wet))*ringLight(p,normal,world);
color=mix(color,vec3(.16,.32,.50),fresnel*.8);
for(int i=0;i<2;i++){if(i>=uLightCount)break;color+=uLightColors[i]*pow(max(0.0,dot(reflect(d,normal),uLightDirections[i])),160.0)*uLightFlux[i]*1.7*ringEnvironmentShadow(p,uLightDirections[i])*vegetationShadow(world,i);}
color+=vec3(.5)*pow(1.0-wet,8.0)*.2;
} else {
float longitude=atan(p.z,p.x)/6.2831853;
float panels=step(.04,fract(longitude*600.0))*step(.04,fract(p.y*uRadius/8000.0));
color=mix(vec3(.055,.07,.085),vec3(.16,.20,.23),panels)*ringLight(p,normal,world);
color+=vec3(.015,.07,.09)*(1.0-panels)*.25;
}
fragColor=vec4(color,1);
float forwardDistance=best*uRadius*max(.0001,dot(worldRay,uForward));
gl_FragDepth=log2(1.0+forwardDistance)/log2(uFar+1.0);
}

View File

@@ -0,0 +1,88 @@
#include "noise.glsl"
uniform int uRingShadowEnabled;
uniform vec3 uRingSize; // half width, hull thickness, wall thickness / radius
uniform float uRingRadius;
uniform float uRingAirHeight;
uniform vec3 uRingCameraBody; // body position / radius
uniform mat4 uRingWorldToBody;
uniform vec3 uRingNoiseOffset;
uniform float uRingTime;
uniform int uRingCloudEnabled;
uniform int uRingCloudHasMap;
uniform sampler2D uRingCloudMap;
uniform float uRingCloudMapStrength;
uniform vec3 uRingCloudLayer; // base/radius, thickness/radius, density
uniform vec3 uRingCloudShape; // shape scale in metres, coverage, extinction per metre
uniform vec3 uRingCloudWind;
uniform float uRingCloudShadowStrength;
vec2 cylinder(vec3 o,vec3 d,float radius) {
float a=dot(d.xz,d.xz);
if(a<1e-12)return length(o.xz)<radius?vec2(-1e10,1e10):vec2(1e10,-1e10);
float middle=-dot(o.xz,d.xz)/a;
vec2 closest=o.xz+d.xz*middle;
float h=radius*radius-dot(closest,closest);
if(h<0.0)return vec2(1e10,-1e10);
h=sqrt(h/a);return vec2(middle-h,middle+h);
}
vec2 slab(vec3 o,vec3 d,float halfWidth) {
if(abs(d.y)<1e-10)return abs(o.y)<=halfWidth?vec2(-1e10,1e10):vec2(1e10,-1e10);
vec2 t=(vec2(-halfWidth,halfWidth)-o.y)/d.y;return vec2(min(t.x,t.y),max(t.x,t.y));
}
vec4 shellSegments(vec3 o,vec3 d,float inner,float outer,float halfWidth,float stop) {
vec2 c=cylinder(o,d,outer),s=slab(o,d,halfWidth),hole=cylinder(o,d,inner);
float a=max(0.0,max(c.x,s.x)),b=min(stop,min(c.y,s.y));
if(hole.y<=a||hole.x>=b||hole.x>hole.y)return vec4(a,b,1.0,-1.0);
return vec4(a,min(b,hole.x),max(a,hole.y),b);
}
bool ringSolidHit(vec3 o,vec3 d,float inner,float outer,float halfWidth) {
vec4 segments=shellSegments(o,d,inner,outer,halfWidth,1e6);
return segments.y>segments.x||segments.w>segments.z;
}
float ringHullVisibility(vec3 p,vec3 ld) {
// Bias in metres, not a fixed fraction of a planet. The deck and both annular
// walls are solid volumes: their caps and inner/outer faces all block sunlight.
vec3 o=p+ld*(2.0/uRingRadius);
float outer=1.0+uRingSize.y,wallCenter=uRingSize.x+uRingSize.z*.5;
if(ringSolidHit(o,ld,1.0,outer,uRingSize.x))return 0.0;
if(ringSolidHit(o-vec3(0,wallCenter,0),ld,1.0-uRingAirHeight,outer,uRingSize.z*.5))return 0.0;
if(ringSolidHit(o+vec3(0,wallCenter,0),ld,1.0-uRingAirHeight,outer,uRingSize.z*.5))return 0.0;
return 1.0;
}
float ringCloudDensity(vec3 p) {
if(uRingCloudEnabled==0||abs(p.y)>uRingSize.x)return 0.0;
float altitude=1.0-length(p.xz);
float h=(altitude-uRingCloudLayer.x)/uRingCloudLayer.y;
if(h<=0.0||h>=1.0)return 0.0;
vec3 wind=uRingCloudWind*uRingTime/uRingRadius;
vec3 q=p-wind;
vec2 uv=vec2(.5-atan(q.z,q.x)/6.28318530718,clamp(.5-q.y/(uRingSize.x*2.0),0.0,1.0));
float map=uRingCloudHasMap!=0?textureLod(uRingCloudMap,uv,0.0).r:1.0;
float shape=noise3((p*uRingRadius-uRingCloudWind*uRingTime)/uRingCloudShape.x+uRingNoiseOffset)*.5+.5;
float coverage=mix(uRingCloudShape.y,map,uRingCloudMapStrength*float(uRingCloudHasMap));
float density=smoothstep(1.0-coverage,1.16-coverage,shape);
return density*smoothstep(0.0,.2,h)*(1.0-smoothstep(.65,1.0,h))*uRingCloudLayer.z;
}
float ringCloudOpticalDepth(vec3 p,vec3 ld,vec2 interval) {
float length=max(0.0,interval.y-interval.x);
if(length<=0.0)return 0.0;
float density=0.0;
for(int i=0;i<6;i++)density+=ringCloudDensity(p+ld*(interval.x+length*(float(i)+.5)/6.0));
return density*length*uRingRadius*uRingCloudShape.z/6.0;
}
float ringCloudTransmission(vec3 p,vec3 ld) {
if(uRingCloudEnabled==0||uRingCloudShadowStrength<=0.0)return 1.0;
vec4 segments=shellSegments(p,ld,1.0-uRingCloudLayer.x-uRingCloudLayer.y,
1.0-uRingCloudLayer.x,uRingSize.x,1e6);
float optical=ringCloudOpticalDepth(p,ld,segments.xy)+ringCloudOpticalDepth(p,ld,segments.zw);
return mix(1.0,exp(-min(30.0,optical)),uRingCloudShadowStrength);
}
float ringEnvironmentShadow(vec3 p,vec3 ld) {
if(ringHullVisibility(p,ld)<.5)return 0.0;
return ringCloudTransmission(p,ld);
}
float ringEnvironmentShadowWorld(vec3 world,vec3 worldLight) {
if(uRingShadowEnabled==0)return 1.0;
vec3 p=uRingCameraBody+mat3(uRingWorldToBody)*world/uRingRadius;
return ringEnvironmentShadow(p,mat3(uRingWorldToBody)*worldLight);
}

View File

@@ -0,0 +1,44 @@
#version 330 core
#include "ring-world-common.glsl"
in vec3 vBody;
in vec3 vDirection;
in vec3 vNormal;
in vec3 vWorld;
in float vFragDepth;
in vec3 vMaterial;
uniform int uMaterialsEnabled;
uniform vec3 uMaterialSettings;
uniform sampler2D uFlatColor;
uniform sampler2D uSlopeColor;
uniform sampler2D uSurfaceTexture;
uniform int uHasSurfaceTexture;
uniform vec3 uSurfaceColor;
uniform float uFar;
out vec4 fragColor;
vec3 triColor(sampler2D raster,vec3 p,vec3 w) {
return texture(raster,p.yz).rgb*w.x+texture(raster,p.xz).rgb*w.y+texture(raster,p.xy).rgb*w.z;
}
void main() {
vec3 n=normalize(vNormal);
// Cylindrical UVs have constant physical scale across the band, including at the rims.
// Analytic derivatives keep the longitude seam from selecting the coarsest mip level.
vec2 uv=ringUv(vBody),dx=ringUvDerivative(vBody,dFdx(vBody)),dy=ringUvDerivative(vBody,dFdy(vBody));
vec3 color=uHasSurfaceTexture!=0?textureGrad(uSurfaceTexture,uv,dx,dy).rgb:uSurfaceColor;
// Metre-scale variation supplements the geographic albedo at walking distance.
float detail=noise3(vBody*uRadius*.13)*.12+noise3(vBody*uRadius*.7)*.045;
color*=1.0+detail*(1.0-smoothstep(100.0,3000.0,length(vWorld)));
float slope=1.0-max(0.0,dot(n,-normalize(vec3(vBody.x,0,vBody.z))));
color=mix(color,vec3(.22,.20,.17),smoothstep(.16,.65,slope)*.7);
if(uMaterialsEnabled!=0) {
vec3 weights=pow(abs(n),vec3(4));weights/=max(.0001,weights.x+weights.y+weights.z);
vec3 materialUv=vMaterial/uMaterialSettings.x;
vec3 flatColor=triColor(uFlatColor,materialUv,weights);
// Retain the generated biome hue when adding the close-range ground texture.
float grain=dot(flatColor,vec3(.2126,.7152,.0722));
vec3 material=mix(color*clamp(grain*4.0,.45,1.7),triColor(uSlopeColor,materialUv,weights),smoothstep(.06,.3,slope));
float strength=uMaterialSettings.z*(1.0-smoothstep(uMaterialSettings.y*.35,uMaterialSettings.y,length(vWorld)));
color=mix(color,material,strength);
}
fragColor=vec4(color*ringLight(vBody,n,vWorld),1);
gl_FragDepth=log2(max(.0001,vFragDepth))/log2(uFar+1.0);
}

View File

@@ -0,0 +1,27 @@
#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 uBodyOrigin;
uniform float uRadius;
uniform mat4 uViewProjection;
uniform mat4 uBodyToWorld;
uniform float uFar;
uniform vec3 uMaterialAnchor;
out vec3 vMaterial;
out vec3 vBody;
out vec3 vDirection;
out vec3 vNormal;
out vec3 vWorld;
out float vFragDepth;
void main() {
vWorld=uChunkPosition+mat3(uBodyToWorld)*aPosition;
vMaterial=aPosition+uMaterialAnchor;
vBody=uBodyOrigin+aPosition/uRadius;
vDirection=aDirection; vNormal=aNormal;
vec4 clip=uViewProjection*vec4(vWorld,1);
vFragDepth=clip.w+1.0;
gl_Position=clip;
gl_Position.z=(2.0*log2(max(.0001,vFragDepth))/log2(uFar+1.0)-1.0)*clip.w;
}

View File

@@ -1,6 +1,7 @@
#version 330 core
#define VEGETATION_FOLIAGE
#include "vegetation-shadow.glsl"
#include "ring-world-shadow.glsl"
in vec3 vWorldPosition;
in vec3 vStablePosition;
in vec2 vUv;
@@ -40,7 +41,7 @@ void main(){
for(int i=0;i<2;i++){
if(i>=uLightCount)break;float sunHeight=dot(uPlanetUp,uLightDirections[i]);
vec3 extinction=exp(-vec3(.035,.07,.16)/(max(sunHeight,.04)+.08));
light+=uLightColors[i]*extinction*uLightFlux[i]*smoothstep(-.025,.018,sunHeight)*max(.12,dot(normal,uLightDirections[i]))*vegetationShadow(vWorldPosition,i);
light+=uLightColors[i]*extinction*uLightFlux[i]*smoothstep(-.025,.018,sunHeight)*max(.12,dot(normal,uLightDirections[i]))*vegetationShadow(vWorldPosition,i)*ringEnvironmentShadowWorld(vWorldPosition,uLightDirections[i]);
}
fragColor=vec4(tex.rgb*light*(.9+vVariation.x*.2),1);
gl_FragDepth=clamp(log2(max(.0001,vFragDepth))/log2(uFar+1.0),0.0,1.0);

View File

@@ -1,6 +1,7 @@
#version 330 core
#define VEGETATION_FOLIAGE
#include "vegetation-shadow.glsl"
#include "ring-world-shadow.glsl"
in vec3 vWorldPosition;
in vec3 vNormal;
in vec3 vStablePosition;
@@ -62,7 +63,7 @@ void main(){
if(i>=uLightCount)break;
vec3 ld=uLightDirections[i];float ndl=max(0.0,dot(normal,ld));
float back=uFoliage!=0?pow(max(0.0,dot(view,-ld)),3.0)*.32:0.0;
float visibility=vegetationShadow(vWorldPosition,i);
float visibility=vegetationShadow(vWorldPosition,i)*ringEnvironmentShadowWorld(vWorldPosition,uLightDirections[i]);
float sunHeight=dot(uPlanetUp,ld);
vec3 extinction=exp(-vec3(.035,.07,.16)/(max(sunHeight,.04)+.08));
vec3 sunlight=uLightColors[i]*extinction*uLightFlux[i]*visibility*smoothstep(-.025,.018,sunHeight);

View File

@@ -0,0 +1,145 @@
using System.Numerics;
using Silk.NET.OpenGL;
using Universe.App.Runtime;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.World;
namespace Universe.App.Graphics;
internal sealed partial class UniverseRenderer
{
private ShaderProgram _ringWorldTerrain = null!, _ringWorldHull = null!, _ringWorldAir = null!;
internal bool RingVegetationShadows { get; set; } = true;
internal bool RingCloudShadows { get; set; } = true;
private void InitializeRingWorld()
{
Vegetation.BindEnvironmentShadows = BindRingShadowSettings;
_ringWorldTerrain = Program("ring-world.vert", "ring-world-terrain.frag");
_ringWorldHull = Program("fullscreen.vert", "ring-world-hull.frag");
_ringWorldAir = Program("fullscreen.vert", "ring-world-air.frag");
}
private void ReloadRingWorldShaders()
{
var replacements = new List<ShaderProgram>();
try
{
replacements.Add(Program("ring-world.vert", "ring-world-terrain.frag"));
replacements.Add(Program("fullscreen.vert", "ring-world-hull.frag"));
replacements.Add(Program("fullscreen.vert", "ring-world-air.frag"));
}
catch { foreach (var p in replacements) p.Dispose(); throw; }
DisposeRingWorld();
_ringWorldTerrain = replacements[0]; _ringWorldHull = replacements[1]; _ringWorldAir = replacements[2];
}
private void DisposeRingWorld() { _ringWorldTerrain.Dispose(); _ringWorldHull.Dispose(); _ringWorldAir.Dispose(); }
internal void BindRingShadowSettings(ShaderProgram shader, PlanetData planet, DVector3 bodyCamera)
{
// Set sampler units even on spherical bodies; GLSL forbids active samplers of
// different types sharing the default unit, including behind a disabled branch.
shader.Set("uNoise", 4); shader.Set("uRingCloudMap", 10);
shader.Set("uRingShadowEnabled", planet.IsRingWorld ? 1 : 0);
if (planet.RingWorld is not { } ring) return;
shader.Set("uRingRadius", (float)ring.RadiusMeters);
shader.Set("uRingSize", new Vector3((float)(ring.WidthMeters * .5 / ring.RadiusMeters),
(float)(ring.ThicknessMeters / ring.RadiusMeters), (float)(ring.WallThicknessMeters / ring.RadiusMeters)));
shader.Set("uRingAirHeight", (float)(planet.AtmosphereHeightMeters / ring.RadiusMeters));
shader.Set("uRingCameraBody", (bodyCamera / ring.RadiusMeters).ToVector3());
shader.SetMatrix("uRingWorldToBody", planet.Orientation.WorldToBody);
shader.Set("uRingTime", (float)(_time % 100000));
var cloud = planet.Clouds;
shader.Set("uRingNoiseOffset", ProceduralNoise.SeedOffset(cloud?.Seed ?? planet.Seed).ToVector3());
shader.Set("uRingCloudEnabled", cloud is { Enabled: true } ? 1 : 0);
shader.Set("uRingCloudLayer", cloud is null ? Vector3.Zero : new((float)(cloud.BaseAltitudeMeters / ring.RadiusMeters),
(float)(cloud.ThicknessMeters / ring.RadiusMeters), (float)cloud.Density));
shader.Set("uRingCloudShape", cloud is null ? Vector3.One : new((float)cloud.ShapeScaleMeters, (float)cloud.Coverage, (float)cloud.ExtinctionPerMeter));
shader.Set("uRingCloudWind", cloud is null ? Vector3.Zero : CloudLighting.Wind(cloud).ToVector3());
shader.Set("uRingCloudHasMap", planet.CloudDensityMap is null ? 0 : 1);
shader.Set("uRingCloudMapStrength", (float)(cloud?.DensityTextureStrength ?? 0));
shader.Set("uRingCloudShadowStrength", RingCloudShadows ? (float)(cloud?.ShadowStrength ?? 0) : 0);
BindTexture(10, planet.CloudDensityMap is { } map ? GetCloudDensityTexture(planet, map) : _emptyCloudDensity.Handle);
}
private void BindRingWorld(ShaderProgram shader, PlanetData planet, StarSystemData system, FlyCamera camera, DVector3 relative, float far)
{
if (!_planetTextures.TryGetValue(planet.Id, out var textures)) _planetTextures.Add(planet.Id, textures = new(_gl, planet));
shader.Use(); BindPlanetTerrain(shader, planet, textures); SetCameraRay(shader, camera);
BindRingShadowSettings(shader, planet, planet.Orientation.ToBody(-relative));
Vegetation.BindShadows(shader, RingVegetationShadows ? planet.Id : null, 2);
shader.Set("uSurfaceTexture", 5); shader.Set("uHasSurfaceTexture", planet.SurfaceTexture is null ? 0 : 1);
shader.Set("uSurfaceColor", planet.SurfaceColor);
shader.SetMatrix("uBodyToWorld", planet.Orientation.BodyToWorld);
shader.Set("uCameraBody", planet.Orientation.ToBody(-relative / planet.RadiusMeters).ToVector3());
var r = planet.RingWorld!;
shader.Set("uRingSize", new Vector3((float)(r.WidthMeters * .5 / r.RadiusMeters), (float)(r.ThicknessMeters / r.RadiusMeters), (float)(r.WallThicknessMeters / r.RadiusMeters)));
shader.Set("uAirHeight", (float)(planet.AtmosphereHeightMeters / r.RadiusMeters));
shader.Set("uAirDensity", (float)planet.AtmosphereDensity);
shader.Set("uAirColor", planet.AtmosphereColor);
shader.Set("uFar", far); shader.Set("uTime", (float)(_time % 100000));
BindBodyLighting(shader, system, planet);
var water = planet.Ocean;
shader.Set("uWaterEnabled", water is { Enabled: true } ? 1 : 0);
shader.Set("uWaterLevel", (float)((water?.SeaLevelMeters ?? 0) / r.RadiusMeters));
shader.Set("uWaterDeep", water is null ? new Vector3(.008f, .07f, .14f) : new((float)water.DeepColor[0], (float)water.DeepColor[1], (float)water.DeepColor[2]));
shader.Set("uWaterShallow", water is null ? new Vector3(.025f, .28f, .24f) : new((float)water.ShallowColor[0], (float)water.ShallowColor[1], (float)water.ShallowColor[2]));
shader.Set("uWaves", water is null ? Vector3.Zero : new((float)water.WaveAmplitudeMeters, (float)water.WaveLengthMeters, (float)water.WaveSpeed));
}
private void DrawRingWorlds(FlyCamera camera, StarSystemData system, Matrix4x4 vp, float far)
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
foreach (var planet in system.Planets.Where(p => p.IsRingWorld))
{
var relative = center.Add(planet.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters);
if (DVector3.Dot(relative, camera.Forward) < -planet.MaximumTerrainRadiusMeters || relative.Length > planet.RadiusMeters * 2000) continue;
if (!_planetMeshes.TryGetValue(planet.Id, out var mesh)) _planetMeshes.Add(planet.Id, mesh = new(_gl, planet));
BindRingWorld(_ringWorldTerrain, planet, system, camera, relative, far);
_ringWorldTerrain.SetMatrix("uViewProjection", vp);
var materialSettings = planet.Materials;
var materialsEnabled = materialSettings.Enabled && materialSettings.Strength > 0
&& PlanetSurface.Distance(planet, -relative) < materialSettings.FadeDistanceMeters;
_ringWorldTerrain.Set("uMaterialsEnabled", materialsEnabled ? 1 : 0);
_ringWorldTerrain.Set("uMaterialSettings", new Vector3((float)materialSettings.ScaleMeters, (float)materialSettings.FadeDistanceMeters, (float)materialSettings.Strength));
_ringWorldTerrain.Set("uFlatColor", 11); _ringWorldTerrain.Set("uSlopeColor", 13);
if (materialsEnabled)
{
var materials = _materials.ForPlanet(planet);
BindTexture(11, materials.FlatColor); BindTexture(13, materials.SlopeColor);
}
var bodyCamera = planet.Orientation.ToBody(-relative);
mesh.Draw(bodyCamera, planet.Orientation.ToBody(camera.Forward), planet.Orientation.ToBody(camera.Right), planet.Orientation.ToBody(camera.Up),
_height, (double)_width / _height, System.Math.Tan(camera.FieldOfViewRadians * .5), (origin, gpu) =>
{
_ringWorldTerrain.Set("uChunkPosition", (relative + planet.Orientation.ToWorld(origin)).ToVector3());
_ringWorldTerrain.Set("uBodyOrigin", (origin / planet.RadiusMeters).ToVector3());
var scale = materialSettings.ScaleMeters;
static double Mod(double v, double s) => v - System.Math.Floor(v / s) * s;
_ringWorldTerrain.Set("uMaterialAnchor", new Vector3((float)Mod(origin.X, scale), (float)Mod(origin.Y, scale), (float)Mod(origin.Z, scale)));
gpu.Draw();
});
TerrainTiles += mesh.DrawnTiles; TerrainPendingTiles += mesh.PendingTiles; TerrainUploads += mesh.UploadedTiles;
TerrainDeepestLevel = System.Math.Max(TerrainDeepestLevel, mesh.DeepestLevel);
if (mesh.NearestDistance < TerrainNearestDistance) { TerrainNearestDistance = mesh.NearestDistance; TerrainNearestLevel = mesh.NearestLevel; }
BindRingWorld(_ringWorldHull, planet, system, camera, relative, far);
_gl.Enable(GLEnum.DepthTest); _gl.DepthMask(true); _fullscreen.Draw();
}
}
private RenderTarget DrawRingWorldAtmospheres(FlyCamera camera, StarSystemData system, float far, RenderTarget source)
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
foreach (var planet in system.Planets.Where(p => p.IsRingWorld).OrderByDescending(p => center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters).Length))
{
var relative = center.Add(planet.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters);
if (relative.Length > planet.RadiusMeters * 100 || DVector3.Dot(relative, camera.Forward) < -planet.MaximumTerrainRadiusMeters) continue;
var target = source == _scatterA ? _scatterB : _scatterA;
target.Bind(); _gl.Disable(GLEnum.DepthTest); _gl.DepthMask(false);
BindRingWorld(_ringWorldAir, planet, system, camera, relative, far);
BindTexture(0, source.Color); BindTexture(1, _scene.Depth);
_ringWorldAir.Set("uScene", 0); _ringWorldAir.Set("uDepth", 1);
_fullscreen.Draw(); source = target;
}
return source;
}
}

View File

@@ -7,7 +7,7 @@ using Universe.Core.World;
namespace Universe.App.Graphics;
internal sealed class UniverseRenderer : IDisposable
internal sealed partial class UniverseRenderer : IDisposable
{
private enum PlanetProbeEffect { Materials, CloudShadows, MountainShadows, BodyShadows, SurfaceNoise }
private static readonly PlanetProbeEffect[] PlanetProbeEffects = Enum.GetValues<PlanetProbeEffect>();
@@ -119,6 +119,7 @@ internal sealed class UniverseRenderer : IDisposable
_starTrails = new(gl, starMotionBlur ?? new());
if (galaxies is not null) _cosmos = new(gl, galaxies, _starTrails);
_surface = Program("terrain.vert", "terrain.frag");
InitializeRingWorld();
_star = Program("star.vert", "star.frag");
_points = Program("point.vert", "point.frag");
_sky = Program("fullscreen.vert", "sky.frag");
@@ -200,6 +201,7 @@ internal sealed class UniverseRenderer : IDisposable
replacements.Add(Program(pair.Item1, pair.Item2));
_starTrails.ReloadShaders();
Vegetation.ReloadShaders();
ReloadRingWorldShaders();
}
catch (Exception error)
{
@@ -344,6 +346,7 @@ internal sealed class UniverseRenderer : IDisposable
Mark("vegetation");
_gpuProfiler.BeginPass("PLANETS TOTAL");
DrawPlanets(camera, system, vp, far, starPositions);
DrawRingWorlds(camera, system, vp, far);
_gpuProfiler.EndPass();
Mark("terrain");
_gpuProfiler.BeginPass("STARS");
@@ -363,6 +366,7 @@ internal sealed class UniverseRenderer : IDisposable
Mark("bodies and ocean");
_gpuProfiler.BeginPass("ATMOSPHERE");
var composed = DrawAtmospheres(camera, system, far);
composed = DrawRingWorldAtmospheres(camera, system, far, composed);
_gpuProfiler.EndPass();
Mark("atmosphere");
_gpuProfiler.BeginPass("CLOUDS / COMPOSITE");
@@ -485,12 +489,12 @@ internal sealed class UniverseRenderer : IDisposable
_surface.SetFloatArray("uLightLuminosities", system.Stars.Select(s => (float)s.LuminositySolar).ToArray());
_surface.Set("uOccluderCount", system.Planets.Count);
_surface.SetVectorArray("uOccluderCenters", relativePlanets.Select(p => p.ToVector3()).ToArray());
_surface.SetFloatArray("uOccluderRadii", system.Planets.Select(p => (float)p.RadiusMeters).ToArray());
_surface.SetFloatArray("uOccluderRadii", system.Planets.Select(p => (float)(p.IsRingWorld ? 0 : p.RadiusMeters)).ToArray());
for (var i = 0; i < system.Planets.Count; i++)
{
var planet = system.Planets[i];
if (onlyPlanetId is not null && planet.Id != onlyPlanetId) continue;
if (planet.IsGasPlanet) continue;
if (planet.IsGasPlanet || planet.IsRingWorld) continue;
var relative = relativePlanets[i];
var blockers=system.Planets.Select((body,index)=>(Body:body,Index:index)).Where(b=>b.Index!=i&&system.Stars.Any(star=>
{
@@ -500,7 +504,7 @@ internal sealed class UniverseRenderer : IDisposable
})).ToArray();
_surface.Set("uOccluderCount",blockers.Length);
_surface.SetVectorArray("uOccluderCenters",blockers.Select(b=>relativePlanets[b.Index].ToVector3()).ToArray());
_surface.SetFloatArray("uOccluderRadii",blockers.Select(b=>(float)b.Body.RadiusMeters).ToArray());
_surface.SetFloatArray("uOccluderRadii",blockers.Select(b=>(float)(b.Body.IsRingWorld ? 0 : b.Body.RadiusMeters)).ToArray());
_surface.Set("uMaximumTerrainRadius",(float)(planet.MaximumTerrainRadiusMeters/planet.RadiusMeters));
if (DVector3.Dot(relative, camera.Forward) < -planet.MaximumTerrainRadiusMeters) continue;
if (planet.RadiusMeters / System.Math.Max(1,relative.Length) * _height / (2 * System.Math.Tan(camera.FieldOfViewRadians*.5)) < .5) continue;
@@ -724,7 +728,7 @@ internal sealed class UniverseRenderer : IDisposable
_gas.Set("uBandFrequency", (float)planet.CloudBandFrequency); _gas.Set("uStormStrength", (float)planet.StormStrength);
_gas.Set("uPlanetIndex", planet.Index); _gas.Set("uOccluderCount", system.Planets.Count);
_gas.SetVectorArray("uOccluderCenters", system.Planets.Select(p => planet.Orientation.ToBody((p.PositionMeters - planet.PositionMeters) / planet.RadiusMeters).ToVector3()).ToArray());
_gas.SetFloatArray("uOccluderRadii", system.Planets.Select(p => (float)(p.RadiusMeters / planet.RadiusMeters)).ToArray());
_gas.SetFloatArray("uOccluderRadii", system.Planets.Select(p => (float)(p.IsRingWorld ? 0 : p.RadiusMeters / planet.RadiusMeters)).ToArray());
BindBodyLighting(_gas, system, planet); BindRingProfile(_gas, planet);
_fullscreen.Draw();
}
@@ -734,7 +738,7 @@ internal sealed class UniverseRenderer : IDisposable
private void DrawOceans(FlyCamera camera, StarSystemData system, float far)
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var candidates = system.Planets.Where(p => p.Ocean is { Enabled: true }).Select(p => (Planet: p,
var candidates = system.Planets.Where(p => !p.IsRingWorld && p.Ocean is { Enabled: true }).Select(p => (Planet: p,
Relative: center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)))
.Where(p => DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.MaximumTerrainRadiusMeters)
.OrderByDescending(p => p.Relative.Length);
@@ -802,7 +806,7 @@ internal sealed class UniverseRenderer : IDisposable
if (!_oceanRendered) return source;
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
// Only a nearby ocean needs detailed SSR; orbital views retain the cheaper analytic reflection.
var planet = system.Planets.Where(p => p.Ocean is { Enabled: true, ScreenSpaceReflections: { Enabled: true, Strength: > 0 } })
var planet = system.Planets.Where(p => !p.IsRingWorld && p.Ocean is { Enabled: true, ScreenSpaceReflections: { Enabled: true, Strength: > 0 } })
.Where(p => System.Math.Abs(center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters).Length
- p.RadiusMeters - p.Ocean!.SeaLevelMeters) < p.Ocean.ScreenSpaceReflections.MaxSurfaceDistanceMeters)
.MinBy(p => center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters).Length - p.RadiusMeters);
@@ -949,7 +953,7 @@ internal sealed class UniverseRenderer : IDisposable
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var candidates = system.Planets.Select(p => (Planet: p, Relative: center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)))
.Where(p => !p.Planet.IsGasPlanet && p.Planet.AtmosphereHeightMeters > 0 && p.Relative.Length < p.Planet.RadiusMeters * 35 && DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.RadiusMeters * 1.04)
.Where(p => !p.Planet.IsGasPlanet && !p.Planet.IsRingWorld && p.Planet.AtmosphereHeightMeters > 0 && p.Relative.Length < p.Planet.RadiusMeters * 35 && DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.RadiusMeters * 1.04)
.OrderBy(p => p.Relative.Length / p.Planet.RadiusMeters).Take(3).OrderByDescending(p => p.Relative.Length).ToArray();
var source = _scene;
_gl.Disable(GLEnum.DepthTest); _gl.DepthMask(false);
@@ -1048,7 +1052,7 @@ internal sealed class UniverseRenderer : IDisposable
private RenderTarget DrawClouds(FlyCamera camera, StarSystemData system, float far, RenderTarget source)
{
var center = UniversePosition.AtCellCenter(system.Coordinate, camera.CellSizeMeters);
var candidates = system.Planets.Where(p => p.Clouds is { Enabled: true, Coverage: > 0, Density: > 0 }).Select(p => (Planet: p,
var candidates = system.Planets.Where(p => !p.IsRingWorld && p.Clouds is { Enabled: true, Coverage: > 0, Density: > 0 }).Select(p => (Planet: p,
Relative: center.Add(p.PositionMeters, camera.CellSizeMeters).RelativeTo(camera.Position, camera.CellSizeMeters)))
.Where(p => p.Relative.Length < p.Planet.RadiusMeters * 35
&& DVector3.Dot(p.Relative, camera.Forward) > -p.Planet.RadiusMeters - p.Planet.Clouds!.BaseAltitudeMeters - p.Planet.Clouds.ThicknessMeters)
@@ -1112,7 +1116,7 @@ internal sealed class UniverseRenderer : IDisposable
var visibility = 1d;
foreach (var planet in system.Planets)
{
if (planet.AtmosphereHeightMeters <= 0) continue;
if (planet.AtmosphereHeightMeters <= 0 || planet.IsRingWorld) continue;
var radial = local - planet.PositionMeters;
var altitude = radial.Length - planet.RadiusMeters;
if (altitude > planet.AtmosphereHeightMeters) continue;
@@ -1221,6 +1225,7 @@ internal sealed class UniverseRenderer : IDisposable
{
if (_disposed) return;
_disposed = true;
DisposeRingWorld();
foreach (var mesh in _planetMeshes.Values) mesh.Dispose();
_planetMeshes.Clear();
foreach (var textures in _planetTextures.Values) textures.Dispose();

View File

@@ -69,6 +69,7 @@ internal static unsafe class VegetationPreparation
{
var catalog=VegetationCatalog.Load(Path.Combine(directory,"vegetation.json"));
using var program=ShaderProgram.Create(gl,ShaderSources.Load("vegetation.vert"),ShaderSources.Load("vegetation.frag"));
program.Use();program.Set("uNoise",4);program.Set("uRingCloudMap",10);program.Set("uRingShadowEnabled",0);
var instance=gl.GenBuffer();gl.BindBuffer(GLEnum.ArrayBuffer,instance);
float[] data=[0,0,0,1,0,1,0,-System.MathF.PI/2,.5f,0,1,.5f];
fixed(float* p=data)gl.BufferData(GLEnum.ArrayBuffer,(nuint)(data.Length*4),p,GLEnum.StaticDraw);

View File

@@ -56,6 +56,7 @@ internal sealed unsafe class VegetationRenderer : IDisposable
private Dictionary<(int Plant,int Variant),Batch>? _farSpare;private uint[] _farBuildingBuffers=[];
private CancellationTokenSource _farCancellation=new();private long _lastFarBuild;
public bool Enabled {get;set;}=true;
internal Action<ShaderProgram,PlanetData,DVector3>? BindEnvironmentShadows { get; set; }
public bool DepthReady {get;private set;}
public double Aspect {get;set;}=1.8;
public int Instances {get;private set;}
@@ -86,7 +87,7 @@ internal sealed unsafe class VegetationRenderer : IDisposable
if(!Enabled){CpuMilliseconds=0;UploadMilliseconds=0;return;}
var center=UniversePosition.AtCellCenter(system.Coordinate,camera.CellSizeMeters);
var planet=system.Planets.Where(p=>p.Vegetation is {Enabled:true}&&!p.IsGasPlanet)
.MinBy(p=>center.Add(p.PositionMeters,camera.CellSizeMeters).RelativeTo(camera.Position,camera.CellSizeMeters).Length-p.RadiusMeters);
.MinBy(p=>PlanetSurface.Distance(p,-center.Add(p.PositionMeters,camera.CellSizeMeters).RelativeTo(camera.Position,camera.CellSizeMeters)));
if(planet is null){if(Stream is not null){Stream.Dispose();ResetFar();}Stream=null;PlanetId=null;_planet=null;_requiredPaths.Clear();return;}
if(PlanetId!=planet.Id||_planet?.Seed!=planet.Seed||_systemId!=system.Id)
{
@@ -95,10 +96,10 @@ internal sealed unsafe class VegetationRenderer : IDisposable
_requiredPaths.Clear();foreach(var p in Catalog.Plants)if(planet.Vegetation!.AllowedPlants is null||planet.Vegetation.AllowedPlants.Contains(p.Id,StringComparer.Ordinal))_requiredPaths.Add(p.Model);
}
_planet=planet;_cameraBody=planet.Orientation.ToBody(-center.Add(planet.PositionMeters,camera.CellSizeMeters).RelativeTo(camera.Position,camera.CellSizeMeters));
_surfaceAltitude=_cameraBody.Length-TerrainHeight.Sample(Stream!.Generator.Field.Planet,_cameraBody.Normalized());
_surfaceAltitude=PlanetSurface.Distance(Stream!.Generator.Field.Planet,_cameraBody);
if(_surfaceAltitude>10000){CpuMilliseconds=watch.Elapsed.TotalMilliseconds;return;}
Stream!.Update(_cameraBody);
var biome=Stream.Generator.Field.Weights(Stream.Generator.Field.Color(_cameraBody.Normalized()));
var biome=Stream.Generator.Field.Weights(Stream.Generator.Field.Color(planet.IsRingWorld?RingWorldGeometry.TextureDirection(planet,_cameraBody):_cameraBody.Normalized()));
if(Stream.InstanceCount>0||biome.X+biome.Y>.1)
foreach(var definition in Catalog.Plants)
if(_requiredPaths.Contains(definition.Model)&&!_models.ContainsKey(definition.Model)&&!_models.Values.Any(m=>!m.Task.IsCompleted))
@@ -270,7 +271,7 @@ internal sealed unsafe class VegetationRenderer : IDisposable
foreach(var tile in stream.Tiles)
{
// Tile anchors are on the reference sphere; mountains can be kilometres above it.
var tileDistance=(tile.Origin.Normalized()*_cameraBody.Length-_cameraBody).Length;
var tileDistance=(_planet.IsRingWorld?tile.Origin-_cameraBody:tile.Origin.Normalized()*_cameraBody.Length-_cameraBody).Length;
if(tileDistance>nearRange+256)continue;
foreach(var instance in tile.Instances)
{
@@ -341,7 +342,7 @@ internal sealed unsafe class VegetationRenderer : IDisposable
for(var light=0;light<system.Stars.Count;light++)for(var cascade=0;cascade<2;cascade++)
{
var radius=settings.ShadowDistancesMeters[cascade];var ld=(system.Stars[light].PositionMeters-_planet.PositionMeters).Normalized();
var lightBody=_planet.Orientation.ToBody(ld);var localUp=_cameraBody.Normalized();
var lightBody=_planet.Orientation.ToBody(ld);var localUp=_planet.IsRingWorld?-RingWorldGeometry.Radial(_cameraBody):_cameraBody.Normalized();
var axis=DVector3.Cross(localUp,lightBody).Normalized();if(axis.LengthSquared<.1)axis=DVector3.Cross(System.Math.Abs(lightBody.Y)<.9?new(0,1,0):new DVector3(1,0,0),lightBody).Normalized();
// Keep the light-space grid independent of head rotation; snap in double precision.
var vertical=DVector3.Cross(lightBody,axis).Normalized();var target=_cameraBody;
@@ -372,7 +373,8 @@ internal sealed unsafe class VegetationRenderer : IDisposable
}
private void Lighting(ShaderProgram program,StarSystemData system)
{
program.Set("uPlanetUp",_planet!.Orientation.ToWorld(_cameraBody.Normalized()).ToVector3());
BindEnvironmentShadows?.Invoke(program,_planet!,_cameraBody);
program.Set("uPlanetUp",_planet!.Orientation.ToWorld(_planet.IsRingWorld?-RingWorldGeometry.Radial(_cameraBody):_cameraBody.Normalized()).ToVector3());
program.Set("uLightCount",system.Stars.Count);
program.SetVectorArray("uLightDirections",system.Stars.Select(s=>(s.PositionMeters-_planet!.PositionMeters).Normalized().ToVector3()).ToArray());
program.SetVectorArray("uLightColors",system.Stars.Select(s=>s.Color).ToArray());
@@ -451,7 +453,7 @@ internal sealed unsafe class VegetationRenderer : IDisposable
{
if(!Enabled||Stream is null||PlanetId!=planetId)return [];
var range=_planet!.Vegetation!.CollisionDistanceMeters;
return Stream.Tiles.Where(t=>t.Key.Layer==0&&(t.Origin.Normalized()*bodyPosition.Length-bodyPosition).Length<range+400).SelectMany(t=>t.Instances)
return Stream.Tiles.Where(t=>t.Key.Layer==0&&(_planet!.IsRingWorld?t.Origin-bodyPosition:t.Origin.Normalized()*bodyPosition.Length-bodyPosition).Length<range+400).SelectMany(t=>t.Instances)
.Where(i=>(i.BodyPosition-bodyPosition).Length<range&&_models.TryGetValue(Catalog.Plants[i.PlantIndex].Model,out var m)&&m.Ready)
.Select(i=>(i,definition:Catalog.Plants[i.PlantIndex])).Where(x=>x.definition.TrunkCollision is not null)
.Select(x=>new TrunkCapsule(x.i.BodyPosition,x.i.Up,x.definition.TrunkCollision!.RadiusMeters*x.i.Scale,x.definition.TrunkCollision.HeightMeters*x.i.Scale)).ToArray();

View File

@@ -97,6 +97,7 @@ public sealed class FlyCamera
.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!;
if (planet.IsRingWorld) { RequestRingInterior(target.System, planet, 2); return; }
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);
@@ -132,6 +133,7 @@ public sealed class FlyCamera
.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!;
if (planet.IsRingWorld) { RequestRingInterior(target.System, planet, 1); return; }
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);
@@ -203,6 +205,7 @@ public sealed class FlyCamera
var nearest = candidates
.MinBy(body => World(body.System, body.Planet.PositionMeters).RelativeTo(Position, CellSizeMeters).Length - body.Planet.RadiusMeters);
var planet = nearest.Planet;
if (planet.IsRingWorld && surface) { RequestRingInterior(nearest.System, planet, 0); return; }
var bodyPosition = World(nearest.System, planet.PositionMeters);
var sun = (nearest.System.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized();
var side = Perpendicular(sun);
@@ -275,8 +278,19 @@ public sealed class FlyCamera
up = RotateAround(pole, forward, v.RollDegrees * System.Math.PI / 180);
}
speed = 250_000;
if (planet.IsRingWorld)
{
radial = planet.Orientation.ToWorld(new DVector3(1, 1.35, .65).Normalized());
forward = -radial; up = planet.Orientation.ToWorld(new(0, 1, 0));
}
}
BeginFlight(bodyPosition, radial, finalRadius, System.Math.Max(planet.MaximumTerrainRadiusMeters * 5, finalRadius * 1.4), forward, up, speed);
if (planet.IsRingWorld)
{
var startBody = planet.Orientation.ToBody(Position.RelativeTo(bodyPosition, CellSizeMeters));
if (new DVector3(startBody.X, 0, startBody.Z).Length < planet.RadiusMeters && System.Math.Abs(startBody.Y) < planet.RingWorld!.WidthMeters * .5)
_flight = _flight! with { RingExit = true, StartRadial = planet.Orientation.ToWorld(new(0, 1, 0)), ApproachRadius = planet.RadiusMeters * 1.5 };
}
}
public void RequestStarView(IEnumerable<StarSystemData> systems)
@@ -329,6 +343,48 @@ public sealed class FlyCamera
private UniversePosition World(StarSystemData system, DVector3 offset) => UniversePosition.AtCellCenter(system.Coordinate, CellSizeMeters).Add(offset, CellSizeMeters);
public static DVector3 RingInteriorPoint(StarSystemData system, PlanetData planet, int view = 0)
{
var sun = planet.Orientation.ToBody((system.Stars[planet.HostStarIndex].PositionMeters - planet.PositionMeters).Normalized());
var best = double.NegativeInfinity; var selected = RingWorldGeometry.Point(planet, 0, 0, 1000);
for (var row = -4; row <= 4; row++) for (var i = 0; i < 144; i++)
{
var angle = i * System.Math.Tau / 144; var across = row * planet.RingWorld!.WidthMeters / 12;
var point = RingWorldGeometry.Point(planet, angle, across);
var d = RingWorldGeometry.TextureDirection(planet, point);
var wet = planet.OceanMask?.Sample(d) ?? 0;
if (view == 1 ? wet < .95 : wet > .05) continue;
if (view != 1 && planet.Ocean is { Enabled: true } ocean
&& RingWorldGeometry.Elevation(planet, point) <= ocean.SeaLevelMeters + 1) continue;
var flatness = DVector3.Dot(RingWorldGeometry.Contact(planet, point).Up, -RingWorldGeometry.Radial(point));
if (view == 0 && flatness < .98) continue;
var color = planet.SurfaceTexture?.SampleColor(d) ?? new System.Numerics.Vector3(45, 80, 35);
var score = DVector3.Dot(-RingWorldGeometry.Radial(point), sun) * 2
+ (color.Y - color.X) / 80 - System.Math.Abs(across) / planet.RingWorld.WidthMeters * .2 + flatness * 2;
if (score <= best) continue;
best = score;
var up = -RingWorldGeometry.Radial(point);
selected = view == 1
? RingWorldGeometry.Radial(point) * (planet.RadiusMeters - planet.Ocean!.SeaLevelMeters - 8) + new DVector3(0, across, 0)
: point + up * (view == 2 ? (planet.Clouds!.BaseAltitudeMeters + planet.Clouds.ThicknessMeters + 1000) : 1000);
}
return selected;
}
private void RequestRingInterior(StarSystemData system, PlanetData planet, int view)
{
var local = RingInteriorPoint(system, planet, view);
var up = planet.Orientation.ToWorld(-RingWorldGeometry.Radial(local));
var tangent = planet.Orientation.ToWorld(DVector3.Cross(new(0, 1, 0), -RingWorldGeometry.Radial(local)));
var point = planet.Orientation.ToWorld(local);
var body = World(system, planet.PositionMeters);
var from = planet.Orientation.ToBody(Position.RelativeTo(body, CellSizeMeters));
var inside = new DVector3(from.X, 0, from.Z).Length < planet.RadiusMeters && System.Math.Abs(from.Y) < planet.RingWorld!.WidthMeters * .5;
BeginFlight(body, point.Normalized(), point.Length, planet.RadiusMeters * 1.5,
(tangent - up * .08).Normalized(), up, view == 0 ? 100 : 10, duration: 6);
_flight = _flight! with { RingInterior = true, RingDirect = inside, StartRadial = planet.Orientation.ToWorld(new(0, 1, 0)) };
}
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)
{
@@ -384,7 +440,7 @@ public sealed class FlyCamera
var system = current.NearbySystems.Append(current.ActiveSystem).FirstOrDefault(s => s.Coordinate == nearest.System.Coordinate);
var planet = system?.Planets.FirstOrDefault(p => p.Id == nearest.Planet.Id);
if (planet is null) return;
if (offset.Length - TerrainHeight.Sample(nearest.Planet, offset.Normalized()) < 100)
if (PlanetSurface.Distance(nearest.Planet, offset) < 100)
{
DVector3 Turn(DVector3 v) => planet.Orientation.ToWorld(nearest.Planet.Orientation.ToBody(v));
offset = Turn(offset); _forward = Turn(_forward); _up = Turn(_up);
@@ -402,7 +458,29 @@ public sealed class FlyCamera
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)
if (f.RingExit)
{
if (t < .3) proposed = f.Start.Add(f.Body.RelativeTo(f.Start, CellSizeMeters) * Smooth(t / .3), CellSizeMeters);
else if (t < .55) proposed = f.Body.Add(f.StartRadial * f.ApproachRadius * Smooth((t - .3) / .25), CellSizeMeters);
else
{
var portal = f.StartRadial * f.ApproachRadius;
proposed = f.Body.Add(DVector3.Lerp(portal, f.TargetRadial * f.FinalRadius, Smooth((t - .55) / .45)), CellSizeMeters);
}
}
else if (f.RingInterior)
{
var destination = f.Body.Add(f.TargetRadial * f.FinalRadius, CellSizeMeters);
if (f.RingDirect) proposed = f.Start.Add(destination.RelativeTo(f.Start, CellSizeMeters) * Smooth(t), CellSizeMeters);
else if (t < .3)
{
var portal = f.Body.Add(f.StartRadial * f.ApproachRadius, CellSizeMeters);
proposed = f.Start.Add(portal.RelativeTo(f.Start, CellSizeMeters) * Smooth(t / .3), CellSizeMeters);
}
else if (t < .55) proposed = f.Body.Add(f.StartRadial * f.ApproachRadius * (1 - Smooth((t - .3) / .25)), CellSizeMeters);
else proposed = f.Body.Add(f.TargetRadial * f.FinalRadius * Smooth((t - .55) / .45), CellSizeMeters);
}
else 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;
@@ -474,6 +552,11 @@ public sealed class FlyCamera
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);
double Duration, double StartFov, double TargetFov, Departure? Departure)
{
public bool RingInterior { get; init; }
public bool RingDirect { get; init; }
public bool RingExit { get; init; }
}
private sealed record Departure(UniversePosition Body, DVector3 StartRadial, DVector3 TargetRadial, double StartRadius, double ClearanceRadius);
}

View File

@@ -13,6 +13,9 @@ public sealed class PlayerController
private DVector3 _heading, _velocity;
private double _pitch;
private bool _jumpHeld;
private string? _ringId;
private SectorCoordinate _ringSystem;
private DVector3 _ringLocalPosition;
public UniversePosition Position { get; private set; }
public bool Grounded { get; private set; }
public bool Crouching { get; private set; }
@@ -26,7 +29,7 @@ public sealed class PlayerController
{
Position = position; _cellSize = cellSize;
var target = Nearest(neighborhood);
Up = target is { } t ? position.RelativeTo(t.Body, cellSize).Normalized() : fallbackUp.Normalized();
Up = target is { } t ? PlanetSurface.GravityUp(t.Planet, position.RelativeTo(t.Body, cellSize)) : 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);
@@ -48,9 +51,11 @@ public sealed class PlayerController
if (planet is null) return;
var body = UniversePosition.AtCellCenter(system!.Coordinate, _cellSize).Add(planet.PositionMeters, _cellSize);
var relative = Position.RelativeTo(old.Body, _cellSize);
if (old.Planet.IsRingWorld && _ringId == old.Planet.Id && _ringSystem == old.System.Coordinate)
relative = old.Planet.Orientation.ToWorld(_ringLocalPosition);
// Use the rotating local frame near the surface, including jumps: the player inherits the ground's
// rotational motion instead of the planet sliding hundreds of metres sideways during a jump.
if (Grounded || relative.Length - TerrainHeight.Sample(old.Planet, relative.Normalized()) <= System.Math.Max(2000, old.Planet.AtmosphereHeightMeters))
if (Grounded || PlanetSurface.Distance(old.Planet, relative) <= System.Math.Max(2000, old.Planet.AtmosphereHeightMeters))
{
DVector3 Rotate(DVector3 v) => planet.Orientation.ToWorld(old.Planet.Orientation.ToBody(v));
relative = Rotate(relative); _heading = Rotate(_heading); Up = Rotate(Up); _velocity = Rotate(_velocity);
@@ -65,6 +70,13 @@ public sealed class PlayerController
var target = Nearest(neighborhood);
if (target is not { } t) { _jumpHeld = jump; return; }
var relative = Position.RelativeTo(t.Body, _cellSize);
if (t.Planet.IsRingWorld)
{
if (_ringId == t.Planet.Id && _ringSystem == t.System.Coordinate)
relative = t.Planet.Orientation.ToWorld(_ringLocalPosition);
UpdateRing(dt, forward, side, jump, crouch, t.System, t.Planet, t.Body, relative); return;
}
_ringId = null;
var radial = relative.Normalized();
if (radial == DVector3.Zero) radial = Up;
Align(radial);
@@ -139,6 +151,81 @@ public sealed class PlayerController
_heading = heading.LengthSquared > .0001 ? heading.Normalized() : Tangent(up);
Up = up;
}
private void UpdateRing(double dt, double forward, double side, bool jump, bool crouch,
StarSystemData system, PlanetData planet, UniversePosition body, DVector3 relative)
{
var contact = PlanetSurface.Contact(planet, relative);
Align(PlanetSurface.GravityUp(planet, relative));
var previousEye = EyeHeight;
Grounded = contact.Clearance <= previousEye + .08 && DVector3.Dot(_velocity, Up) <= .05
&& CanStandOnRing(contact, Up);
Crouching = crouch;
// Correct eye height along gravity only. Re-projecting onto the normal each frame
// creeps sideways on curved terrain, even with no input and no celestial motion.
if (Grounded) relative = RingEyePosition(planet, relative, EyeHeight);
var right = DVector3.Cross(_heading, Up).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 horizontal = input * (Crouching ? 1.5 : 4.5);
var vertical = DVector3.Dot(_velocity, Up);
if (Grounded && jump && !_jumpHeld) { vertical = 5.2; Grounded = false; }
_jumpHeld = jump;
if (Grounded)
{
var next = MoveThroughTrees(planet, relative, relative + horizontal * dt, Up);
var nextContact = PlanetSurface.Contact(planet, next);
// Follow the curved surface while retaining the local tangent heading around the seam.
var rise = DVector3.Dot(nextContact.Point - contact.Point, Up);
if (CanStandOnRing(nextContact, PlanetSurface.GravityUp(planet, next))
&& rise <= .45 && (horizontal.Length * dt < .001 || rise <= horizontal.Length * dt * .85))
relative = RingEyePosition(planet, next, EyeHeight);
SetRingPosition(system, planet, body, relative);
Align(PlanetSurface.GravityUp(planet, relative));
_velocity = horizontal - Up * DVector3.Dot(horizontal, Up);
return;
}
var altitude = System.Math.Max(0, contact.Clearance - EyeHeight);
vertical = altitude > 2000 && vertical <= 0
? -System.Math.Max(-vertical, System.Math.Min(altitude / 5, _cellSize * .02))
: System.Math.Max(-180, vertical - planet.RingWorld!.GravityMetersPerSecondSquared * dt);
_velocity = horizontal + Up * vertical;
var destination = MoveThroughTrees(planet, relative, relative + _velocity * dt, Up);
var collision = PlanetCollisionSolver.Sweep(body.Add(relative, _cellSize), body.Add(destination, _cellSize),
UniversePosition.AtCellCenter(system.Coordinate, _cellSize), [planet], _cellSize, EyeHeight);
Position = collision.Position;
var resolved = Position.RelativeTo(body, _cellSize);
SetRingPosition(system, planet, body, resolved);
Align(PlanetSurface.GravityUp(planet, resolved));
if (collision.Collided)
{
var hit = PlanetSurface.Contact(planet, resolved);
Grounded = CanStandOnRing(hit, Up) && DVector3.Dot(_velocity, Up) <= .05;
var intoSurface = DVector3.Dot(_velocity, hit.Up);
if (intoSurface < 0) _velocity -= hit.Up * intoSurface;
}
}
private void SetRingPosition(StarSystemData system, PlanetData planet, UniversePosition body, DVector3 relative)
{
// World coordinates are quantised at millimetres in a 100 AU cell. Do not feed that
// rounding back into the next frame's local position as the habitat orbits/spins.
_ringId = planet.Id; _ringSystem = system.Coordinate;
_ringLocalPosition = planet.Orientation.ToBody(relative);
Position = body.Add(relative, _cellSize);
}
private static DVector3 RingEyePosition(PlanetData planet, DVector3 relative, double eye)
{
var up = PlanetSurface.GravityUp(planet, relative);
for (var i = 0; i < 3; i++)
{
var contact = PlanetSurface.Contact(planet, relative);
var error = eye - contact.Clearance;
if (System.Math.Abs(error) < .001) break;
relative += up * (error / System.Math.Max(.2, DVector3.Dot(contact.Up, up)));
}
return relative;
}
private static bool CanStandOnRing(SurfaceContact contact, DVector3 up)
=> DVector3.Dot(contact.Up, up) >= 1 / System.Math.Sqrt(1 + .85 * .85);
private DVector3 MoveThroughTrees(PlanetData planet,DVector3 start,DVector3 end,DVector3 up)
{
if(TrunksProvider is null)return end;
@@ -156,7 +243,7 @@ public sealed class PlayerController
{
if (planet.IsGasPlanet) continue;
var body = UniversePosition.AtCellCenter(system.Coordinate, _cellSize).Add(planet.PositionMeters, _cellSize);
var distance = Position.RelativeTo(body, _cellSize).Length - planet.RadiusMeters;
var distance = PlanetSurface.Distance(planet, Position.RelativeTo(body, _cellSize));
if (distance >= closest) continue;
closest = distance; best = (system, planet, body);
}

View File

@@ -84,7 +84,7 @@ public sealed class UniverseApplication : IDisposable
var options = WindowOptions.Default with
{
Title = "Universe | loading",
IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--shadow-report" or "--lookaround-report" or "--prepare-vegetation" or "--vegetation-report" or "--simulation-report" or "--graphics-report" or "--scene-report" or "--ocean-report" or "--cloud-report" or "--terrain-report" or "--menu-report" or "--terrain-perf-report" or "--black-hole-report" or "--galaxy-journey-report" or "--star-trails-report"),
IsVisible = !Environment.GetCommandLineArgs().Any(a => a is "--ring-world-report" or "--shadow-report" or "--lookaround-report" or "--prepare-vegetation" or "--vegetation-report" or "--simulation-report" or "--graphics-report" or "--scene-report" or "--ocean-report" or "--cloud-report" or "--terrain-report" or "--menu-report" or "--terrain-perf-report" or "--black-hole-report" or "--galaxy-journey-report" or "--star-trails-report"),
Size = Environment.GetCommandLineArgs().Contains("--vegetation-report")?new Vector2D<int>(2560,1440):new Vector2D<int>(1440, 900),
WindowBorder = Environment.GetCommandLineArgs().Contains("--vegetation-report")?WindowBorder.Hidden:WindowBorder.Resizable,
VSync = true,
@@ -108,6 +108,8 @@ public sealed class UniverseApplication : IDisposable
{VegetationPreparation.Run(gl,Path.Combine(AppContext.BaseDirectory,"../../../../..","Content","Vegitations"));_window.Close();return;}
_input = _window.CreateInput();
_renderer = new(gl, _window.FramebufferSize.X, _window.FramebufferSize.Y, _galaxies, _starMotionBlur);
if(Environment.GetCommandLineArgs().Contains("--ring-world-report"))
{GraphicsReport.RunRingWorld(gl,_renderer,_camera,_neighborhood,_window.FramebufferSize.X,_window.FramebufferSize.Y);_window.Close();return;}
if(Environment.GetCommandLineArgs().Contains("--lookaround-report"))
{GraphicsReport.RunLookaround(gl,_renderer,_camera,_neighborhood,_window.FramebufferSize.X,_window.FramebufferSize.Y);_window.Close();return;}
if(Environment.GetCommandLineArgs().Contains("--vegetation-report"))

View File

@@ -28,7 +28,11 @@ public sealed class KnownSolarSystemCatalog
var catalog = new KnownSolarSystemCatalog(Path.GetDirectoryName(fullPath)!);
foreach (var definition in document.Systems)
{
var system = catalog.Build(definition, cellSizeMeters);
if (definition.Planets is null || definition.PlanetFiles is null)
throw new InvalidDataException($"'{definition.Id}': planets and planetFiles cannot be null.");
var expanded = definition with { Planets = [..definition.Planets,
..definition.PlanetFiles.Select(file => JsonSettings.Read<PlanetDefinition>(catalog.AssetPath(file)))] };
var system = catalog.Build(expanded, 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}.");
}
@@ -99,6 +103,9 @@ public sealed class KnownSolarSystemCatalog
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}'.");
p.RingWorld?.Validate(p.RadiusMeters, air.HeightMeters, terrain, p.Ocean);
if (p.RingWorld is not null && (p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant || p.Rings is not null))
throw new InvalidDataException($"'{p.Id}': a ring world is a solid habitat, not a gas giant or a particle ring system.");
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;
@@ -111,7 +118,16 @@ public sealed class KnownSolarSystemCatalog
|| 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 planetSeed = p.Seed ?? StableSeed(systemId + "/" + p.Id);
RingWorldSurfaceMaps? generated = null;
if (p.RingWorld is { ProceduralSurface: not null } ring)
{
if (p.SurfaceTexture is not null || terrain.Heightmap is not null || terrain.RegionalHeight is not null)
throw new InvalidDataException($"'{p.Id}': proceduralSurface generates surfaceTexture and heightmap; remove external surface/height/regional maps.");
generated = RingWorldSurface.GetOrCreate(ring, planetSeed, terrain.HeightmapMinMeters, terrain.HeightmapMaxMeters,
terrain.HeightmapGamma, p.Ocean is { Enabled: true } water ? water.SeaLevelMeters : null);
}
var heightmap = generated?.Height ?? (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
@@ -127,12 +143,11 @@ public sealed class KnownSolarSystemCatalog
throw new InvalidDataException($"'{p.Id}': rotationPeriodHours must be finite and nonzero; pole longitude must be finite.");
var orbit = BuildOrbit(p.Orbit, (parent?.MassKg ?? stars[host].MassKg) + p.MassKg);
var position = (parent?.PositionMeters ?? stars[host].PositionMeters) + orbit.Position(0);
var surfaceTexture = p.SurfaceTexture is null ? null : LoadColor(p.SurfaceTexture);
var surfaceTexture = generated?.Color ?? (p.SurfaceTexture is null ? null : LoadColor(p.SurfaceTexture));
var vegetation=p.Vegetation??(p.SurfaceType==PlanetSurfaceType.Terrestrial&&air.HeightMeters>0?new VegetationSettings():null);
vegetation?.Validate();
if(vegetation is {Enabled:true}&&p.SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant)
throw new InvalidDataException($"'{p.Id}': vegetation requires solid ground.");
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;
@@ -157,6 +172,7 @@ public sealed class KnownSolarSystemCatalog
Description = p.Description,
ParentPlanetIndex = parent?.Index,
SurfaceType = p.SurfaceType,
RingWorld = p.RingWorld,
Gas = p.Gas,
CloudBandFrequency = p.CloudBandFrequency,
StormStrength = p.StormStrength,
@@ -167,7 +183,8 @@ public sealed class KnownSolarSystemCatalog
Ocean = p.Ocean,
Clouds = clouds,
CloudDensityMap = cloudDensityMap,
OceanMask = p.Ocean is { Enabled: true, MaskFromSurfaceTexture: true } ? OceanSurface.CreateMask(surfaceTexture!) : null,
OceanMask = p.Ocean is { Enabled: true } ? generated?.OceanMask
?? (p.Ocean.MaskFromSurfaceTexture ? OceanSurface.CreateMask(surfaceTexture!) : null) : null,
AtmosphereDensity = air.Density,
AtmosphereComposition = air.Composition,
Orientation = new(p.AxialTiltDegrees, p.RotationDegrees, p.PoleLongitudeDegrees),

View File

@@ -22,6 +22,7 @@ public sealed record SolarSystemDefinition
public double[] CenterOffsetMeters { get; init; } = [0, 0, 0];
public required List<StarDefinition> Stars { get; init; }
public required List<PlanetDefinition> Planets { get; init; }
public List<string> PlanetFiles { get; init; } = [];
}
public sealed record StarDefinition
@@ -115,6 +116,7 @@ public sealed record PlanetDefinition
public double RotationPeriodHours { get; init; } = 24;
public double PoleLongitudeDegrees { get; init; }
public PlanetSurfaceType SurfaceType { get; init; }
public RingWorldSettings? RingWorld { get; init; }
public GasVolumeSettings Gas { get; init; } = new();
public double CloudBandFrequency { get; init; } = 28;
public double StormStrength { get; init; }

View File

@@ -41,6 +41,8 @@ public sealed record PlanetData(
public string Description { get; init; } = string.Empty;
public PlanetSurfaceType SurfaceType { get; init; }
public bool IsGasPlanet => SurfaceType is PlanetSurfaceType.GasGiant or PlanetSurfaceType.IceGiant;
public RingWorldSettings? RingWorld { get; init; }
public bool IsRingWorld => RingWorld is not null;
public GasVolumeSettings Gas { get; init; } = new();
public double CloudBandFrequency { get; init; } = 28;
public double StormStrength { get; init; }
@@ -57,7 +59,7 @@ public sealed record PlanetData(
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
public double MaximumTerrainRadiusMeters => RingWorld is { } ring ? ring.BoundingRadiusMeters : 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);

View File

@@ -0,0 +1,144 @@
using Universe.Core.Configuration;
using Universe.Core.Math;
namespace Universe.Core.Generation;
/// <summary>Dimensions of the habitable inner cylinder. The Y axis runs across the band.</summary>
public sealed record RingWorldSettings
{
public double DiameterMeters { get; init; } = 5_000_000;
public double WidthMeters { get; init; } = 500_000;
public double ThicknessMeters { get; init; } = 50_000;
public double WallThicknessMeters { get; init; } = 5_000;
public double GravityMetersPerSecondSquared { get; init; } = 9.81;
public RingWorldSurfaceSettings? ProceduralSurface { get; init; }
public double RadiusMeters => DiameterMeters * .5;
public double BoundingRadiusMeters => System.Math.Sqrt(System.Math.Pow(RadiusMeters + ThicknessMeters, 2)
+ System.Math.Pow(WidthMeters * .5 + WallThicknessMeters, 2));
public void Validate(double radius, double atmosphere, TerrainDefinition terrain, OceanSettings? ocean)
{
var values = new[] { DiameterMeters, WidthMeters, ThicknessMeters, WallThicknessMeters, GravityMetersPerSecondSquared };
if (values.Any(v => !double.IsFinite(v) || v <= 0) || !double.IsFinite(BoundingRadiusMeters)
|| System.Math.Abs(radius - RadiusMeters) > .001 || atmosphere <= 0 || atmosphere >= RadiusMeters
|| terrain.HeightmapMaxMeters + terrain.ProceduralNoiseAmplitudeMeters * 1.2 >= atmosphere
|| terrain.HeightmapMinMeters - terrain.ProceduralNoiseAmplitudeMeters * 1.2 <= -ThicknessMeters
|| ocean is { Enabled: true } && (ocean.SeaLevelMeters - ocean.DepthMeters <= -ThicknessMeters || ocean.SeaLevelMeters >= atmosphere))
throw new InvalidDataException("Invalid ringWorld: positive finite dimensions/gravity required; radiusMeters must equal diameterMeters/2, and terrain/ocean must fit between the hull and atmospheric rim.");
ProceduralSurface?.Validate();
}
}
public readonly record struct SurfaceContact(DVector3 Point, DVector3 Up, double Clearance);
public static class RingWorldGeometry
{
public static DVector3 Radial(DVector3 p)
{
var r = new DVector3(p.X, 0, p.Z).Normalized();
return r == DVector3.Zero ? new(1, 0, 0) : r;
}
// Adapter for the shared raster sampler: U wraps the circumference and V spans the clear width.
public static DVector3 TextureDirection(PlanetData planet, DVector3 bodyPoint)
{
var latitude = System.Math.Clamp(bodyPoint.Y / planet.RingWorld!.WidthMeters, -.5, .5) * System.Math.PI;
return Radial(bodyPoint) * System.Math.Cos(latitude) + new DVector3(0, System.Math.Sin(latitude), 0);
}
public static DVector3 Point(PlanetData planet, double angle, double across, double altitude = 0)
{
var radial = new DVector3(System.Math.Cos(angle), 0, System.Math.Sin(angle));
var basePoint = radial * planet.RadiusMeters + new DVector3(0, across, 0);
var height = Elevation(planet, basePoint);
return basePoint - radial * (height + altitude);
}
public static double Elevation(PlanetData planet, DVector3 bodyPoint)
{
// Keep procedural features at the same physical scale along and across the ring.
// Using the spherical texture direction for noise would compress a planet's mountains
// into the narrow band and produce steep parallel ridges at walking scale.
var domain = Radial(bodyPoint) + new DVector3(0, bodyPoint.Y / planet.RadiusMeters, 0);
return TerrainHeight.ElevationAtBodyDirection(planet, TextureDirection(planet, bodyPoint), domain);
}
public static DVector3 PatchPoint(PlanetData planet, TerrainPatchKey key, double u = .5, double v = .5)
=> Point(planet, (key.Face + (key.X + u) / (1 << key.Level)) * System.Math.Tau / 6,
((key.Y + v) / (1 << key.Level) - .5) * planet.RingWorld!.WidthMeters);
private static double BoxDistance(double x, double y, double hx, double hy)
{
var a = System.Math.Abs(x) - hx; var b = System.Math.Abs(y) - hy;
return System.Math.Sqrt(System.Math.Pow(System.Math.Max(0, a), 2) + System.Math.Pow(System.Math.Max(0, b), 2))
+ System.Math.Min(0, System.Math.Max(a, b));
}
// Signed distance in the (cylindrical radius, width) section; positive in air, negative in the hull.
public static double Clearance(PlanetData planet, DVector3 bodyPoint)
{
var ring = planet.RingWorld!;
var r = new DVector3(bodyPoint.X, 0, bodyPoint.Z).Length;
var elevation = Elevation(planet, bodyPoint);
var floor = ring.RadiusMeters - elevation; var outer = ring.RadiusMeters + ring.ThicknessMeters;
var deck = BoxDistance(r - (floor + outer) * .5, bodyPoint.Y, (outer - floor) * .5, ring.WidthMeters * .5);
var rim = ring.RadiusMeters - planet.AtmosphereHeightMeters;
var wall = BoxDistance(r - (rim + outer) * .5, System.Math.Abs(bodyPoint.Y) - (ring.WidthMeters + ring.WallThicknessMeters) * .5,
(outer - rim) * .5, ring.WallThicknessMeters * .5);
return System.Math.Min(deck, wall);
}
public static SurfaceContact Contact(PlanetData planet, DVector3 bodyPoint)
{
const double epsilon = .25;
var distance = Clearance(planet, bodyPoint);
var point = bodyPoint; var up = -Radial(bodyPoint);
// A radial height residual is not a metric distance on a slope. Project to the actual
// surface for collision clearance; this normal does not define the gravity frame.
for (var iteration = 0; iteration < 6; iteration++)
{
double Sample(double x, double y, double z) => Clearance(planet, point + new DVector3(x, y, z));
var gradient = new DVector3(Sample(epsilon, 0, 0) - Sample(-epsilon, 0, 0),
Sample(0, epsilon, 0) - Sample(0, -epsilon, 0), Sample(0, 0, epsilon) - Sample(0, 0, -epsilon)) / (2 * epsilon);
if (new DVector3(point.X, 0, point.Z).Length < epsilon)
gradient = new((Sample(epsilon, 0, 0) - Clearance(planet, point)) / epsilon, gradient.Y, 0);
if (gradient.LengthSquared < 1e-12) gradient = up;
up = gradient.Normalized();
if (iteration == 0 && System.Math.Abs(distance) > 200)
return new(bodyPoint - up * distance, up, distance);
var residual = Clearance(planet, point);
if (System.Math.Abs(residual) < .0001) break;
point -= gradient * (residual / gradient.LengthSquared);
}
return new(point, up, System.Math.CopySign((bodyPoint - point).Length, distance));
}
}
/// <summary>Shared world-space contact queries for locomotion and camera attachment.</summary>
public static class PlanetSurface
{
/// <summary>Up opposite gravity, independent of terrain slopes and ring side walls.</summary>
public static DVector3 GravityUp(PlanetData planet, DVector3 relative)
{
if (planet.IsRingWorld)
return planet.Orientation.ToWorld(-RingWorldGeometry.Radial(planet.Orientation.ToBody(relative)));
var up = relative.Normalized();
return up == DVector3.Zero ? new(0, 1, 0) : up;
}
public static double Distance(PlanetData planet, DVector3 relative) => planet.IsRingWorld
? RingWorldGeometry.Contact(planet, planet.Orientation.ToBody(relative)).Clearance
: relative.Length - TerrainHeight.Sample(planet, relative);
public static SurfaceContact Contact(PlanetData planet, DVector3 relative)
{
if (planet.IsRingWorld)
{
var contact = RingWorldGeometry.Contact(planet, planet.Orientation.ToBody(relative));
return new(planet.Orientation.ToWorld(contact.Point), planet.Orientation.ToWorld(contact.Up), contact.Clearance);
}
var up = relative.Normalized(); if (up == DVector3.Zero) up = new(0, 1, 0);
var height = TerrainHeight.Sample(planet, up);
return new(up * height, up, relative.Length - height);
}
}

View File

@@ -0,0 +1,163 @@
using System.Numerics;
using Universe.Core.Math;
namespace Universe.Core.Generation;
public sealed record RingWorldSurfaceSettings
{
public double MetersPerTexel { get; init; } = 1000;
public int MaxTextureSize { get; init; } = 16384;
public double ContinentScaleMeters { get; init; } = 180_000;
public double BiomeScaleMeters { get; init; } = 90_000;
public double MountainScaleMeters { get; init; } = 30_000;
public double OceanCoverage { get; init; } = .55;
public void Validate()
{
if (new[] { MetersPerTexel, ContinentScaleMeters, BiomeScaleMeters, MountainScaleMeters }.Any(v => !double.IsFinite(v) || v <= 0)
|| MaxTextureSize is < 64 or > 16384 || !double.IsFinite(OceanCoverage) || OceanCoverage is < 0 or > 1)
throw new InvalidDataException("ringWorld.proceduralSurface requires positive finite metre scales, maxTextureSize in [64,16384] and oceanCoverage in [0,1].");
}
}
public sealed record RingWorldSurfaceMaps(RasterImage Color, HeightMap Height, HeightMap OceanMask);
/// <summary>Seamless cylindrical geography in metres. Albedo, elevation and water share one coastline.</summary>
public static class RingWorldSurface
{
// Bound allocation even when a very large ring or a tiny texel size is requested (96 MiB of map data).
public const int MaximumTexels = 8_388_608;
private sealed record CacheKey(RingWorldSettings Ring, ulong Seed, double Minimum, double Maximum, double Gamma, double? Sea);
private static readonly object CacheLock = new();
private static CacheKey? _cachedKey;
private static RingWorldSurfaceMaps? _cachedMaps;
internal static RingWorldSurfaceMaps GetOrCreate(RingWorldSettings ring, ulong seed, double minimum, double maximum, double gamma, double? sea)
{
var key = new CacheKey(ring, seed, minimum, maximum, gamma, sea);
// A single-entry cache also lets catalogue reloads share immutable image buffers; it cannot grow without bound.
lock (CacheLock)
{
if (_cachedKey == key) return _cachedMaps!;
var maps = Generate(ring, seed, minimum, maximum, gamma, sea);
_cachedKey = key; _cachedMaps = maps;
return maps;
}
}
public static (int Width, int Height) Resolution(RingWorldSettings ring)
{
var settings = ring.ProceduralSurface ?? throw new InvalidDataException("Missing ringWorld.proceduralSurface.");
settings.Validate();
var circumference = ring.DiameterMeters * System.Math.PI;
if (!double.IsFinite(circumference) || circumference <= 0 || !double.IsFinite(ring.WidthMeters) || ring.WidthMeters <= 0)
throw new InvalidDataException("Procedural ring surface dimensions must be finite and positive.");
var spacing = System.Math.Max(settings.MetersPerTexel, System.Math.Max(circumference, ring.WidthMeters) / settings.MaxTextureSize);
spacing = System.Math.Max(spacing, System.Math.Sqrt(circumference) * System.Math.Sqrt(ring.WidthMeters) / System.Math.Sqrt(MaximumTexels));
int width, height;
do
{
width = System.Math.Clamp((int)System.Math.Ceiling(circumference / spacing), 2, settings.MaxTextureSize);
height = System.Math.Clamp((int)System.Math.Ceiling(ring.WidthMeters / spacing), 2, settings.MaxTextureSize);
spacing *= 1.001;
} while ((long)width * height > MaximumTexels);
return (width, height);
}
public static RingWorldSurfaceMaps Generate(RingWorldSettings ring, ulong seed, double minimum, double maximum, double gamma = 1, double? seaLevel = 0)
{
var (width, height) = Resolution(ring);
var settings = ring.ProceduralSurface!;
var sea = seaLevel ?? minimum;
if (!double.IsFinite(minimum) || !double.IsFinite(maximum) || !double.IsFinite(sea) || minimum > sea || maximum <= sea
|| !double.IsFinite(gamma) || gamma <= 0)
throw new InvalidDataException("Procedural ring height range must contain sea level below its maximum; gamma must be positive and finite.");
var elevations = new float[width * height];
var mask = new float[elevations.Length];
var rgba = new byte[elevations.Length * 4];
var columns = new DVector3[width];
for (var x = 0; x < width; x++)
{
var angle = (.5 - (x + .5) / width) * System.Math.Tau;
columns[x] = new DVector3(System.Math.Cos(angle), 0, System.Math.Sin(angle)) * ring.RadiusMeters;
}
// Mix all 64 seed bits; nearby seeds must not merely translate the same map by a fraction of a texel.
var random = new DeterministicRandom(seed);
DVector3 Offset() => new(random.NextDouble() * 40, random.NextDouble() * 40, random.NextDouble() * 40);
var continentOffset = Offset(); var climateOffset = Offset(); var mountainOffset = Offset();
Parallel.For(0, height, y =>
{
var across = new DVector3(0, (.5 - (y + .5) / height) * ring.WidthMeters, 0);
for (var x = 0; x < width; x++)
{
var p = (columns[x] + across) / settings.ContinentScaleMeters + continentOffset;
var warp = new DVector3(Noise(p + new DVector3(13, 7, 2)), Noise(p + new DVector3(3, 17, 11)), Noise(p + new DVector3(19, 1, 23)));
elevations[y * width + x] = (float)Fractal(p + warp * .8, 5);
}
});
// Quantile selection makes the requested ocean fraction meaningful for any circumference/width/seed.
var coverage = seaLevel.HasValue ? settings.OceanCoverage : 0;
var histogram = new int[4096];
foreach (var value in elevations) histogram[System.Math.Clamp((int)((value + 1) * .5 * (histogram.Length - 1)), 0, histogram.Length - 1)]++;
var threshold = coverage == 0 ? -1d : 1d;
if (coverage is > 0 and < 1)
{
var sum = 0; var target = (int)(coverage * elevations.Length);
for (var bin = 0; bin < histogram.Length; bin++)
{
sum += histogram[bin];
if (sum > target) { threshold = (bin + .5) / (histogram.Length - 1) * 2 - 1; break; }
}
}
Parallel.For(0, height, y =>
{
var across = new DVector3(0, (.5 - (y + .5) / height) * ring.WidthMeters, 0);
for (var x = 0; x < width; x++)
{
var index = y * width + x; var position = columns[x] + across;
var coast = elevations[index] - threshold;
var wet = Smooth(0, .18, -coast);
var inland = Smooth(0, .22, coast);
var moisture = .5 + Fractal(position / settings.BiomeScaleMeters + climateOffset, 3) * 1.15;
var belt = Smooth(-.12, .34, Noise(position / (settings.MountainScaleMeters * 4) + mountainOffset));
var ridge = 1 - System.Math.Abs(Noise(position / settings.MountainScaleMeters + mountainOffset));
var smallRidge = 1 - System.Math.Abs(Noise(position / (settings.MountainScaleMeters * .37) + mountainOffset));
var mountains = belt * System.Math.Pow(ridge, 3) * (.65 + .35 * smallRidge);
var relief = inland * (.035 + .10 * Smooth(0, .65, coast) + .865 * mountains);
var altitude = sea + (maximum - sea) * relief;
elevations[index] = (float)System.Math.Pow(System.Math.Clamp((altitude - minimum) / (maximum - minimum), 0, 1), 1 / gamma);
mask[index] = (float)wet;
// Palette follows the existing vegetation biomes, including broad green and yellow/dry regions.
var color = Vector3.Lerp(new(205, 181, 120), new(145, 150, 80), (float)Smooth(.15, .40, moisture));
color = Vector3.Lerp(color, new(45, 80, 35), (float)Smooth(.40, .65, moisture));
color = Vector3.Lerp(new(205, 181, 120), color, (float)Smooth(.002, .025, coast));
color = Vector3.Lerp(color, new(121, 117, 109), (float)Smooth(.35, .62, relief));
color = Vector3.Lerp(color, new(240, 242, 245), (float)Smooth(.65, .82, relief));
color *= (float)(.96 + .08 * smallRidge);
if (coast < 0) color = Vector3.Lerp(new(30, 119, 135), new(7, 35, 68), (float)wet);
var pixel = index * 4;
rgba[pixel] = (byte)System.Math.Clamp(color.X, 0, 255);
rgba[pixel + 1] = (byte)System.Math.Clamp(color.Y, 0, 255);
rgba[pixel + 2] = (byte)System.Math.Clamp(color.Z, 0, 255); rgba[pixel + 3] = 255;
}
});
return new(new(width, height, rgba), new(width, height, elevations), new(width, height, mask));
}
private static double Noise(DVector3 p) => ProceduralNoise.GradientSample(p);
private static double Fractal(DVector3 p, int octaves)
{
var sum = 0d; var weight = 1d; var total = 0d;
for (var i = 0; i < octaves; i++)
{
sum += Noise(p) * weight; total += weight;
p = ProceduralNoise.RotateOctave(p) * 2.03 + new DVector3(7.1, 13.7, 3.3); weight *= .5;
}
return sum / total;
}
private static double Smooth(double a, double b, double value)
{
var t = System.Math.Clamp((value - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
}
}

View File

@@ -0,0 +1,82 @@
using Universe.Core.Math;
namespace Universe.Core.Generation;
public sealed partial class VegetationTileGenerator
{
private static double RingTileSize(int layer) => layer == 0 ? 256 : layer == 1 ? 128 : 32;
private int RingColumns(int layer) => (int)System.Math.Ceiling(System.Math.Tau * Field.Planet.RadiusMeters / RingTileSize(layer));
public DVector3 RingTileOrigin(VegetationTileKey key) => RingWorldGeometry.Point(Field.Planet,
(key.X + .5) / RingColumns(key.Layer) * System.Math.Tau,
(key.Y + .5) * RingTileSize(key.Layer) - Field.Planet.RingWorld!.WidthMeters * .5);
private IEnumerable<VegetationTileKey> AroundRing(DVector3 camera, int layer, double range)
{
var size = RingTileSize(layer); var columns = RingColumns(layer);
var angle = System.Math.Atan2(camera.Z, camera.X) / System.Math.Tau;
if (angle < 0) angle += 1;
var cx = (int)(angle * columns); var cy = (int)System.Math.Floor((camera.Y + Field.Planet.RingWorld!.WidthMeters * .5) / size);
var cells = (int)System.Math.Ceiling(range / size) + 1;
var rows = (int)System.Math.Ceiling(Field.Planet.RingWorld.WidthMeters / size);
var keys = new List<VegetationTileKey>();
for (var y = System.Math.Max(0, cy - cells); y <= System.Math.Min(rows - 1, cy + cells); y++)
for (var dx = -cells; dx <= cells; dx++)
{
var key = new VegetationTileKey(6, 0, ((cx + dx) % columns + columns) % columns, y, layer);
if ((RingTileOrigin(key) - camera).Length <= range + size * 1.5) keys.Add(key);
}
return keys.OrderBy(k => (RingTileOrigin(k) - camera).LengthSquared);
}
private VegetationTile GenerateRing(VegetationTileKey key, CancellationToken token)
{
var watch = System.Diagnostics.Stopwatch.StartNew();
var planet = Field.Planet; var origin = RingTileOrigin(key); var output = new List<VegetationInstance>();
var plants = _plants[key.Layer];
if (plants.Length == 0 || Settings.DensityMultiplier <= 0) return new(key, origin, []);
var density = (key.Layer == 0 ? Settings.TreeDensityPerHectare : key.Layer == 1 ? Settings.ShrubDensityPerHectare : Settings.GroundDensityPerHectare) / 10000 * Settings.DensityMultiplier;
var size = RingTileSize(key.Layer); var columns = RingColumns(key.Layer);
var spacing = plants.Max(i => Field.Catalog.Plants[i].MinimumSpacingMeters);
var side = System.Math.Max(1, (int)System.Math.Floor(size / System.Math.Max(spacing * 2, 1 / System.Math.Sqrt(System.Math.Max(1e-8, density)))));
Span<double> weights = stackalloc double[plants.Length];
for (var y = 0; y < side; y++) for (var x = 0; x < side; x++)
{
token.ThrowIfCancellationRequested();
var hash = Hash(6, (long)key.X * side + x, (long)key.Y * side + y, key.Layer, _seed);
var random = new DeterministicRandom(hash);
var angle = (key.X + (x + .25 + random.NextDouble() * .5) / side) / columns * System.Math.Tau;
var across = (key.Y + (y + .25 + random.NextDouble() * .5) / side) * size - planet.RingWorld!.WidthMeters * .5;
if (System.Math.Abs(across) >= planet.RingWorld.WidthMeters * .5 - 2) continue;
var point = RingWorldGeometry.Point(planet, angle, across);
var direction = RingWorldGeometry.TextureDirection(planet, point);
if (planet.OceanMask?.Sample(direction) > .2) continue;
var elevation = planet.RadiusMeters - new DVector3(point.X, 0, point.Z).Length;
if (planet.Ocean is { Enabled: true } ocean && elevation < ocean.SeaLevelMeters + .4) continue;
var color = Field.Color(direction); var biome = Field.Weights(color);
var cover = Field.ForestCover(direction);
var probability = key.Layer == 0 ? (biome.X + biome.Y * .25) * cover : (biome.X * .6 + biome.Y) * (.4 + cover * .6);
if (random.NextDouble() > probability) continue;
var up = -RingWorldGeometry.Radial(point);
var normal = RingWorldGeometry.Contact(planet, point).Up;
var latitude = System.Math.Abs(System.Math.Asin(direction.Y) * 180 / System.Math.PI);
var total = 0d;
for (var i = 0; i < plants.Length; i++)
{
var plant = Field.Catalog.Plants[plants[i]];
var eligible = elevation >= plant.AltitudeMeters[0] && elevation <= plant.AltitudeMeters[1]
&& latitude >= plant.AbsoluteLatitudeDegrees[0] && latitude <= plant.AbsoluteLatitudeDegrees[1]
&& DVector3.Dot(normal, up) >= System.Math.Cos(plant.MaxSlopeDegrees * System.Math.PI / 180);
weights[i] = eligible ? VegetationCatalog.Match(color, plant.MatchColorRgb, plant.ColorTolerancePercent) * plant.SpawnWeight : 0;
total += weights[i];
}
if (total <= 0) continue;
var pick = random.NextDouble() * total; var selected = 0;
for (; selected < weights.Length - 1; selected++) { pick -= weights[selected]; if (pick <= 0) break; }
var definition = Field.Catalog.Plants[plants[selected]];
var scale = definition.ScaleRange[0] + random.NextDouble() * (definition.ScaleRange[1] - definition.ScaleRange[0]);
output.Add(new(hash, plants[selected], point - up * (key.Layer == 0 ? .22 : .035), key.Layer == 0 ? up : normal,
scale, random.NextDouble() * System.Math.Tau, random.NextDouble()));
}
return new(key, origin, output.ToArray()) { GenerationMilliseconds = watch.Elapsed.TotalMilliseconds };
}
}

View File

@@ -10,6 +10,11 @@ public static class TerrainHeight
var unitDirection = direction.Normalized();
if (unitDirection == DVector3.Zero) unitDirection = new(0, 1, 0);
var bodyDirection = planet.Orientation.ToBody(unitDirection);
return planet.RadiusMeters + ElevationAtBodyDirection(planet, bodyDirection);
}
public static double ElevationAtBodyDirection(PlanetData planet, DVector3 bodyDirection, DVector3? noiseDomain = null)
{
var terrain = planet.Terrain;
var map = terrain.HeightMap?.Sample(bodyDirection) ?? 0;
var mappedHeight = terrain.HeightMap is null ? 0 : terrain.HeightmapMinMeters
@@ -25,7 +30,7 @@ public static class TerrainHeight
var t = System.Math.Clamp((map - terrain.LandThreshold) / terrain.LandBlend, 0, 1);
mask = t * t * (3 - 2 * t);
}
var noise = planet.TerrainAmplitudeMeters == 0 ? 0 : Elevation(bodyDirection * terrain.NoiseFrequency,
var noise = planet.TerrainAmplitudeMeters == 0 ? 0 : Elevation((noiseDomain ?? bodyDirection) * terrain.NoiseFrequency,
ProceduralNoise.SeedOffset(planet.Seed)) * planet.TerrainAmplitudeMeters * mask;
var elevation = mappedHeight + noise;
if (planet.Ocean is { Enabled: true } ocean && planet.OceanMask is { } water)
@@ -33,7 +38,7 @@ public static class TerrainHeight
var wet = water.Sample(bodyDirection);
elevation += (ocean.SeaLevelMeters - ocean.DepthMeters - elevation) * wet;
}
return planet.RadiusMeters + elevation;
return elevation;
}
public static double Elevation(DVector3 direction, DVector3 offset)

View File

@@ -28,14 +28,16 @@ public static class TerrainPatchBuilder
var side = Segments + 1;
var expanded = side + 2;
var positions = new DVector3[expanded * expanded];
var origin = key.Direction * planet.RadiusMeters;
var origin = planet.IsRingWorld ? RingWorldGeometry.PatchPoint(planet, key) : key.Direction * planet.RadiusMeters;
var startU = -1 + key.X * key.Width;
var startV = -1 + key.Y * key.Width;
for (var y = -1; y <= side; y++)
for (var x = -1; x <= side; x++)
{
var d = TerrainChunkBuilder.FaceDirection(key.Face, startU + key.Width * x / Segments, startV + key.Width * y / Segments).Normalized();
positions[(y + 1) * expanded + x + 1] = d * TerrainHeight.Sample(planet, d);
positions[(y + 1) * expanded + x + 1] = planet.IsRingWorld
? RingWorldGeometry.PatchPoint(planet, key, x / (double)Segments, y / (double)Segments)
: d * TerrainHeight.Sample(planet, d);
}
var vertices = new float[(side * side + side * 4) * 9];
var vertexOffset=0;
@@ -45,8 +47,9 @@ public static class TerrainPatchBuilder
var index = (y + 1) * expanded + x + 1;
var p = positions[index];
var n = DVector3.Cross(positions[index + 1] - positions[index - 1], positions[index + expanded] - positions[index - expanded]).Normalized();
if (DVector3.Dot(n, p) < 0) n = -n;
AddVertex(vertices, ref vertexOffset, p - origin, n, p.Normalized());
var up = planet.IsRingWorld ? -RingWorldGeometry.Radial(p) : p.Normalized();
if (DVector3.Dot(n, up) < 0) n = -n;
AddVertex(vertices, ref vertexOffset, p - origin, n, planet.IsRingWorld ? RingWorldGeometry.TextureDirection(planet, p) : up);
}
var spacing = planet.RadiusMeters * key.Width / Segments;
// A fixed fraction of mountain amplitude creates hundred-metre walls around
@@ -59,8 +62,11 @@ public static class TerrainPatchBuilder
var source = edge switch { 0 => t, 1 => t * side + Segments, 2 => Segments * side + t, _ => t * side };
var p = new DVector3(vertices[source * 9], vertices[source * 9 + 1], vertices[source * 9 + 2]) + origin;
var n = new DVector3(vertices[source * 9 + 3], vertices[source * 9 + 4], vertices[source * 9 + 5]);
var d = p.Normalized();
AddVertex(vertices, ref vertexOffset, p - d * skirtDepth - origin, n, d);
var d = planet.IsRingWorld ? -RingWorldGeometry.Radial(p) : p.Normalized();
// Coarse ring tiles must not extrude their textured skirts through the outer metal hull.
var depth = planet.RingWorld is { } ring ? System.Math.Min(skirtDepth,
System.Math.Max(0, ring.RadiusMeters + ring.ThicknessMeters - new DVector3(p.X, 0, p.Z).Length) * .5) : skirtDepth;
AddVertex(vertices, ref vertexOffset, p - d * depth - origin, n, planet.IsRingWorld ? RingWorldGeometry.TextureDirection(planet, p) : d);
}
}
var vertexData = vertices;

View File

@@ -35,7 +35,7 @@ public sealed class VegetationStream : IDisposable
else if(task.IsFaulted)throw new InvalidOperationException("Vegetation generation failed.",task.Exception);
}
foreach(var key in _tiles.Where(p=>p.Value.Task is null&&p.Value.Tile is null).Select(p=>p.Key).ToArray())_tiles.Remove(key);
var altitude=System.Math.Max(0,camera.Length-Generator.Field.Planet.MaximumTerrainRadiusMeters);
var altitude=System.Math.Max(0,PlanetSurface.Distance(Generator.Field.Planet,camera));
if(altitude>Generator.Settings.TreeDistanceMeters*1.2){CpuMilliseconds=timer.Elapsed.TotalMilliseconds;return;}
// Retain a full neighbourhood. Turning the camera must never invalidate tiles
// or cancel generation behind the player, which is also needed by collision.
@@ -47,7 +47,7 @@ public sealed class VegetationStream : IDisposable
if(altitude>Generator.Range(layer))continue;
foreach(var key in Generator.Around(camera,layer))
{
var offset=key.Direction*camera.Length-camera;
var offset=Generator.Field.Planet.IsRingWorld?Generator.RingTileOrigin(key)-camera:key.Direction*camera.Length-camera;
var distance=offset.Length;
wanted.Add((key,distance+(layer==0?0:layer==1?40:60)));
}

View File

@@ -24,7 +24,7 @@ public readonly record struct VegetationInstance(ulong Id,int PlantIndex,DVector
public sealed record VegetationTile(VegetationTileKey Key,DVector3 Origin,VegetationInstance[] Instances)
{public double GenerationMilliseconds {get;init;}}
public sealed class VegetationTileGenerator
public sealed partial class VegetationTileGenerator
{
public BiomeField Field {get;}
public VegetationSettings Settings {get;}
@@ -43,6 +43,7 @@ public sealed class VegetationTileGenerator
public double Range(int layer)=>layer==0?Settings.TreeDistanceMeters:layer==1?Settings.ShrubDistanceMeters:Settings.GrassDistanceMeters;
public IEnumerable<VegetationTileKey> Around(DVector3 bodyCamera,int layer,double? range=null)
{
if(Field.Planet.IsRingWorld)return AroundRing(bodyCamera,layer,range??Range(layer));
var level=Level(layer);var key=VegetationTileKey.At(bodyCamera,level,layer);
var width=key.Width*Field.Planet.RadiusMeters;
// Cube-face compression is strongest at the corners (sqrt(3)).
@@ -58,6 +59,7 @@ public sealed class VegetationTileGenerator
}
public VegetationTile Generate(VegetationTileKey key,CancellationToken token=default)
{
if(Field.Planet.IsRingWorld)return GenerateRing(key,token);
var watch=System.Diagnostics.Stopwatch.StartNew();
var planet=Field.Planet;var origin=key.Direction*planet.RadiusMeters;
if(_plants[key.Layer].Length==0||Settings.DensityMultiplier<=0)return new(key,origin,[]);

View File

@@ -32,6 +32,49 @@ public static class PlanetCollisionSolver
if (planet.IsGasPlanet) continue;
var center = planet.PositionMeters;
var relativeStart = from - center;
if (planet.IsRingWorld)
{
var length = travel.Length;
var radius = planet.MaximumTerrainRadiusMeters + cameraRadiusMeters;
var aRing = travel.LengthSquared;
var bRing = DVector3.Dot(relativeStart, travel);
var discriminantRing = bRing * bRing - aRing * (relativeStart.LengthSquared - radius * radius);
if (aRing > 0 && discriminantRing < 0) continue;
var enterRing = aRing > 0 ? System.Math.Max(0, (-bRing - System.Math.Sqrt(discriminantRing)) / aRing) : 0;
var exitRing = aRing > 0 ? System.Math.Min(bestT, System.Math.Min(1, (-bRing + System.Math.Sqrt(discriminantRing)) / aRing)) : 0;
var tRing = enterRing;
// Conservative advancement cannot jump over the thin hull, even during an FTL sweep.
var step = 0;
for (; step < 8192 && tRing <= exitRing; step++)
{
var point = relativeStart + travel * tRing;
var clearance = PlanetSurface.Distance(planet, point) - cameraRadiusMeters;
if (clearance <= .002)
{
var contact = PlanetSurface.Contact(planet, point);
if (tRing == 0 && clearance >= -.05 && DVector3.Dot(travel, contact.Up) > 0 && length > 0)
{ tRing += .1 / length; continue; }
if (tRing < bestT)
{
bestT = tRing;
bestPosition = systemCenter.Add(center + contact.Point + contact.Up * (cameraRadiusMeters + .003), cellSizeMeters);
bestPlanet = planet.Index;
}
break;
}
if (length < 1e-12) break;
tRing += System.Math.Max(.001, clearance * .2) / length;
}
// A very long grazing ray can spend its budget millimetres above the ground.
// Keep the last safe position instead of accepting an untested destination.
if (step == 8192 && tRing <= exitRing && tRing < bestT)
{
bestT = tRing;
bestPosition = systemCenter.Add(center + relativeStart + travel * tRing, cellSizeMeters);
bestPlanet = planet.Index;
}
continue;
}
var startSurface = TerrainHeight.Sample(planet, relativeStart) + cameraRadiusMeters;
var startDistance = relativeStart.Length;
var startClearance = startDistance - startSurface;

View File

@@ -13,16 +13,16 @@ public sealed class KnownSolarSystemTests
private static KnownSolarSystemCatalog Load() => KnownSolarSystemCatalog.Load(CatalogPath);
[Fact]
public void SolarSystemContainsSunAndEightPlanetsInOrder()
public void SolarSystemContainsSunEightPlanetsAndRingHabitat()
{
var sol = Load().GetById("sol");
Assert.Equal(new SectorCoordinate(0, 0, 0), sol.Coordinate);
Assert.Equal("sun", Assert.Single(sol.Stars).Id);
Assert.Equal(new[] { "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune" },
sol.Planets.Where(p => p.ParentPlanetIndex is null).Select(p => p.Id));
sol.Planets.Where(p => p.ParentPlanetIndex is null && !p.IsRingWorld).Select(p => p.Id));
Assert.Equal(sol.Planets.Count, sol.Planets.Select(p => p.Id).Distinct().Count());
Assert.Equal(sol.Planets.Where(p => p.ParentPlanetIndex is null).OrderBy(p => p.OrbitRadiusMeters),
sol.Planets.Where(p => p.ParentPlanetIndex is null));
Assert.Equal(sol.Planets.Where(p => p.ParentPlanetIndex is null && !p.IsRingWorld).OrderBy(p => p.OrbitRadiusMeters),
sol.Planets.Where(p => p.ParentPlanetIndex is null && !p.IsRingWorld));
Assert.NotNull(sol.Planets.Single(p => p.Id == "saturn").Rings);
var uranus = sol.Planets.Single(p => p.Id == "uranus");
Assert.InRange(System.Math.Abs(uranus.Orientation.ToWorld(new(0, 1, 0)).Y), 0, 0.2);

View File

@@ -0,0 +1,114 @@
using Universe.Core.Generation;
using Universe.Core.Math;
namespace Universe.Tests;
public sealed class RingWorldSurfaceTests
{
private static RingWorldSettings Settings => new()
{
DiameterMeters = 512_000 / System.Math.PI, WidthMeters = 64_000,
ProceduralSurface = new() { MetersPerTexel = 1000, MaxTextureSize = 1024,
ContinentScaleMeters = 20_000, BiomeScaleMeters = 12_000, MountainScaleMeters = 5000 }
};
[Fact]
public void ResolutionUsesPhysicalDimensionsAndBoundsMemoryWithoutStretching()
{
var normal = new RingWorldSettings { ProceduralSurface = new() };
Assert.Equal((15708, 500), RingWorldSurface.Resolution(normal));
foreach (var ring in new[] { normal, normal with { DiameterMeters = 80_000_000 },
normal with { WidthMeters = 8_000_000 }, normal with { ProceduralSurface = new() { MetersPerTexel = .1, MaxTextureSize = 4096 } } })
{
var (width, height) = RingWorldSurface.Resolution(ring);
Assert.InRange((long)width * height, 4, RingWorldSurface.MaximumTexels);
Assert.InRange(width, 2, ring.ProceduralSurface!.MaxTextureSize);
Assert.InRange(height, 2, ring.ProceduralSurface.MaxTextureSize);
var ratio = (ring.DiameterMeters * System.Math.PI / width) / (ring.WidthMeters / height);
Assert.InRange(ratio, .97, 1.03);
}
}
[Fact]
public void SeedDeterminesColorHeightAndWaterIncludingHighBits()
{
var a = RingWorldSurface.Generate(Settings, 110, 0, 8000);
var same = RingWorldSurface.Generate(Settings, 110, 0, 8000);
var other = RingWorldSurface.Generate(Settings, 110UL | (1UL << 63), 0, 8000);
Assert.Equal(a.Color.Rgba, same.Color.Rgba);
Assert.Equal(a.Height.Samples, same.Height.Samples);
Assert.Equal(a.OceanMask.Samples, same.OceanMask.Samples);
Assert.False(a.Color.Rgba.SequenceEqual(other.Color.Rgba));
Assert.False(a.Height.Samples.SequenceEqual(other.Height.Samples));
Assert.False(a.OceanMask.Samples.SequenceEqual(other.OceanMask.Samples));
}
[Fact]
public void WiderRingExtendsGeographyInsteadOfStretchingExistingFeatures()
{
var ring = Settings with { ProceduralSurface = Settings.ProceduralSurface! with { OceanCoverage = 0 } };
var a = RingWorldSurface.Generate(ring, 110, 0, 8000);
var b = RingWorldSurface.Generate(ring with { WidthMeters = ring.WidthMeters * 2 }, 110, 0, 8000);
Assert.Equal(a.Color.Width, b.Color.Width); Assert.Equal(a.Color.Height * 2, b.Color.Height);
var offset = a.Color.Height / 2 * a.Color.Width;
Assert.Equal(a.Color.Rgba, b.Color.Rgba.Skip(offset * 4).Take(a.Color.Rgba.Length));
Assert.Equal(a.Height.Samples, b.Height.Samples.Skip(offset).Take(a.Height.Samples.Length));
}
[Fact]
public void WaterMountainsAndBiomesAgreeAndWrapContinuously()
{
var maps = RingWorldSurface.Generate(Settings, 110, 0, 8000);
var width = maps.Color.Width; var height = maps.Color.Height;
var water = 0; var green = 0; var dry = 0; var mountains = 0;
for (var i = 0; i < maps.Height.Samples.Length; i++)
{
var r = maps.Color.Rgba[i * 4]; var g = maps.Color.Rgba[i * 4 + 1]; var b = maps.Color.Rgba[i * 4 + 2];
var wet = maps.OceanMask.Samples[i]; var h = maps.Height.Samples[i];
if (wet > 0) { water++; Assert.Equal(0, h); Assert.True(b > r); }
else if (h > .5) mountains++;
else if (g > r * 1.3 && g > b * 1.5) green++;
else if (r > b * 1.4 && g > b * 1.3) dry++;
}
Assert.InRange((double)water / (width * height), .53, .57);
Assert.True(green > 100 && dry > 100 && mountains > 50, $"green={green}, dry={dry}, mountains={mountains}");
double seam = 0, adjacent = 0;
for (var y = 0; y < height; y++)
{
var v = (y + .5) / height;
Assert.InRange(System.Math.Abs(maps.Height.SampleUv(-1e-10, v) - maps.Height.SampleUv(1e-10, v)), 0, 1e-6);
Assert.InRange(System.Math.Abs(maps.OceanMask.SampleUv(-1e-10, v) - maps.OceanMask.SampleUv(1e-10, v)), 0, 1e-6);
seam += System.Math.Abs(maps.Height.Samples[y * width] - maps.Height.Samples[(y + 1) * width - 1]);
adjacent += System.Math.Abs(maps.Height.Samples[y * width] - maps.Height.Samples[y * width + 1]);
}
Assert.InRange(seam / adjacent, .3, 3);
// CPU collision uses precisely these same generated maps, including the sloping seabed.
var planet = new PlanetData(0, "Test", DVector3.Zero, Settings.RadiusMeters, 0, 20_000,
System.Numerics.Vector3.One, System.Numerics.Vector3.One, 0, 0, 0, 110)
{
RingWorld = Settings, Terrain = new() { HeightMap = maps.Height, HeightmapMaxMeters = 8000 },
Ocean = new() { DepthMeters = 4000 }, OceanMask = maps.OceanMask
};
for (var y = 0; y < height; y += 7) for (var x = 0; x < width; x += 13)
{
var angle = (.5 - (x + .5) / width) * System.Math.Tau;
var point = new DVector3(System.Math.Cos(angle) * planet.RadiusMeters,
(.5 - (y + .5) / height) * Settings.WidthMeters, System.Math.Sin(angle) * planet.RadiusMeters);
var index = y * width + x;
var expected = maps.Height.Samples[index] * 8000d * (1 - maps.OceanMask.Samples[index]) - 4000d * maps.OceanMask.Samples[index];
Assert.InRange(System.Math.Abs(RingWorldGeometry.Elevation(planet, point) - expected), 0, 1e-7);
}
}
[Fact]
public void InvalidSettingsAreRejectedAndDisablingOceanProducesDryLand()
{
foreach (var invalid in new[] { new RingWorldSurfaceSettings { MetersPerTexel = 0 },
new() { MaxTextureSize = 32768 }, new() { OceanCoverage = double.NaN },
new() { MountainScaleMeters = -1 }, new() { BiomeScaleMeters = double.PositiveInfinity } })
Assert.Throws<InvalidDataException>(invalid.Validate);
Assert.Throws<InvalidDataException>(() => RingWorldSurface.Generate(Settings, 1, 100, 8000, seaLevel: 0));
var dry = RingWorldSurface.Generate(Settings, 110, 0, 8000, seaLevel: null);
Assert.All(dry.OceanMask.Samples, value => Assert.Equal(0, value));
}
}

View File

@@ -0,0 +1,333 @@
using System.Numerics;
using Universe.App.Runtime;
using Universe.Core.Configuration;
using Universe.Core.Generation;
using Universe.Core.Math;
using Universe.Core.Physics;
using Universe.Core.World;
namespace Universe.Tests;
public sealed class RingWorldTests
{
private const double Cell = 1e10;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void IdlePlayerStaysAtSameBodyPositionOnSlopesAtUniverseScale(bool moving)
{
var cell = UniverseScale.DefaultCellSizeMeters;
var planet = Ring() with { TerrainAmplitudeMeters = 120, Terrain = new() { NoiseFrequency = 24 },
Orientation = new(35, 73), Rotation = new(86400, 35, 73),
Orbit = new(1.7e11, 0, 0, 365 * 86400, new(1, 0, 0), new(0, 0, 1)) };
var basis = new StarSystemData(new(0, 0, 0), 1, [new(0, "Sun", DVector3.Zero, 7e8, 1, Vector3.One, 1)], [planet]);
var world = new UniverseNeighborhood(CelestialSimulation.Evaluate(basis, 0), []);
planet = world.ActiveSystem.Planets[0];
var center = UniversePosition.AtCellCenter(world.ActiveSystem.Coordinate, cell);
var body = center.Add(planet.PositionMeters, cell);
var ground = RingWorldGeometry.Point(planet, .73, 25_123);
var up = -RingWorldGeometry.Radial(ground);
var player = new PlayerController(body.Add(planet.Orientation.ToWorld(ground + up * 1.7), cell),
planet.Orientation.ToWorld(new(0, 1, 0)), planet.Orientation.ToWorld(up), cell, world);
for (var i = 0; i < 120; i++) player.Update(1d / 60, 0, 0, false, false, world);
Assert.True(player.Grounded);
var original = planet.Orientation.ToBody(player.Position.RelativeTo(body, cell));
double maximumDrift = 0;
for (var i = 1; i <= 3600; i++)
{
if (moving)
{
var next = new UniverseNeighborhood(CelestialSimulation.Evaluate(basis, i / 60d), []);
player.ApplyCelestialMotion(world, next); world = next;
}
player.Update(1d / 60, 0, 0, false, false, world);
planet = world.ActiveSystem.Planets[0]; body = center.Add(planet.PositionMeters, cell);
var local = planet.Orientation.ToBody(player.Position.RelativeTo(body, cell));
maximumDrift = System.Math.Max(maximumDrift, (local - original).Length);
}
Assert.True(maximumDrift < .005, $"Idle drift after 60 seconds (moving={moving}): {maximumDrift:F6} metres.");
}
private static PlanetData Ring(double radius = 2_500_000) => new(0, "Ring", DVector3.Zero, radius, 0,
radius * .088, Vector3.One, Vector3.UnitZ, 0, 0, 0, 110)
{
Id = "ring-world", SurfaceType = PlanetSurfaceType.Terrestrial,
RingWorld = new() { DiameterMeters = radius * 2, WidthMeters = radius * .2, ThicknessMeters = radius * .02, WallThicknessMeters = radius * .002 }
};
[Fact]
public void CatalogLoadsDimensionsAndProceduralGeographyWithEarthAtmosphere()
{
var catalog = KnownSolarSystemCatalog.Load(Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json"));
var system = catalog.GetById("sol");
var earth = system.Planets.Single(p => p.Id == "earth"); var ring = system.Planets.Single(p => p.IsRingWorld);
Assert.Equal(5_000_000, ring.RingWorld!.DiameterMeters);
Assert.Equal(500_000, ring.RingWorld.WidthMeters); Assert.Equal(50_000, ring.RingWorld.ThicknessMeters);
Assert.NotSame(earth.SurfaceTexture, ring.SurfaceTexture); Assert.NotSame(earth.Terrain.HeightMap, ring.Terrain.HeightMap);
Assert.Null(ring.SurfaceTexture!.SourcePath); Assert.Null(ring.Terrain.HeightMap!.SourcePath);
Assert.Equal((15708, 500), (ring.SurfaceTexture.Width, ring.SurfaceTexture.Height));
Assert.Equal(ring.SurfaceTexture.Width, ring.OceanMask!.Width);
Assert.Equal(ring.SurfaceTexture.Height, ring.Terrain.HeightMap.Height);
Assert.Null(ring.CloudDensityMap); // Clouds use physical 3D noise rather than a stretched Earth map.
var landing = FlyCamera.RingInteriorPoint(system, ring);
Assert.True(RingWorldGeometry.Elevation(ring, landing) > ring.Ocean!.SeaLevelMeters + 1);
Assert.Equal(earth.AtmosphereHeightMeters, ring.AtmosphereHeightMeters);
Assert.Equal(earth.AtmosphereColor, ring.AtmosphereColor);
Assert.True(ring.Ocean!.Enabled && ring.Clouds!.Enabled && ring.Vegetation!.Enabled);
var config = EngineConfiguration.Load(Path.Combine(AppContext.BaseDirectory, "config.json"));
Assert.Equal(ring.Id, config.Startup.PlanetHotkeys[8]);
}
[Fact]
public void DimensionsMustFitAtmosphereTerrainAndHull()
{
var settings = new RingWorldSettings(); var terrain = new TerrainDefinition();
settings.Validate(2_500_000, 220_000, terrain, null);
foreach (var invalid in new[] { settings with { WidthMeters = 0 }, settings with { DiameterMeters = double.NaN },
settings with { GravityMetersPerSecondSquared = -1 }, settings with { ThicknessMeters = double.PositiveInfinity } })
Assert.Throws<InvalidDataException>(() => invalid.Validate(2_500_000, 220_000, terrain, null));
Assert.Throws<InvalidDataException>(() => settings.Validate(1, 220_000, terrain, null));
Assert.Throws<InvalidDataException>(() => settings.Validate(2_500_000, 2_500_000, terrain, null));
Assert.Throws<InvalidDataException>(() => settings.Validate(2_500_000, 220_000, terrain, new() { DepthMeters = 60_000 }));
}
[Fact]
public void CollisionNormalsFollowFloorAndWallsWhileGravityStaysCylindricalAfterRotation()
{
var ring = Ring() with { Orientation = new(37, 121) };
foreach (var angle in new[] { 0d, 1, 2, 3, 4, 5, 6 })
{
var point = RingWorldGeometry.Point(ring, angle, 0, 30);
var contact = PlanetSurface.Contact(ring, ring.Orientation.ToWorld(point));
Assert.InRange(contact.Clearance, 29.99, 30.01);
Assert.InRange(DVector3.Dot(contact.Up, ring.Orientation.ToWorld(-RingWorldGeometry.Radial(point))), .99999, 1.000001);
Assert.InRange((PlanetSurface.GravityUp(ring, ring.Orientation.ToWorld(point))
- ring.Orientation.ToWorld(-RingWorldGeometry.Radial(point))).Length, 0, 1e-12);
}
var wall = new DVector3(ring.RadiusMeters - 1000, ring.RingWorld!.WidthMeters * .5 - 10, 0);
var nearWall = PlanetSurface.Contact(ring, ring.Orientation.ToWorld(wall));
Assert.InRange(nearWall.Clearance, 9.999, 10.001);
Assert.InRange(DVector3.Dot(nearWall.Up, ring.Orientation.ToWorld(new(0, -1, 0))), .99999, 1.00001);
Assert.InRange((PlanetSurface.GravityUp(ring, ring.Orientation.ToWorld(wall))
- ring.Orientation.ToWorld(new(-1, 0, 0))).Length, 0, 1e-12);
var top = new DVector3(ring.RadiusMeters - ring.AtmosphereHeightMeters - 10, ring.RingWorld.WidthMeters * .5 + 100, 0);
Assert.InRange(RingWorldGeometry.Clearance(ring, top), 9.999, 10.001);
}
[Fact]
public void SweepsHitThinHullAndWallsButPassThroughOpenCenter()
{
var ring = Ring(); var origin = UniversePosition.AtCellCenter(new(0, 0, 0), Cell);
CollisionResult Sweep(DVector3 a, DVector3 b) => PlanetCollisionSolver.Sweep(origin.Add(a, Cell), origin.Add(b, Cell), origin, [ring], Cell, 1.7);
Assert.False(Sweep(new(0, -1e8, 0), new(0, 1e8, 0)).Collided);
var inner = Sweep(new(0, 0, 0), new(1e8, 0, 0));
Assert.True(inner.Collided); Assert.InRange(inner.Position.RelativeTo(origin, Cell).X, ring.RadiusMeters - 1.72, ring.RadiusMeters - 1.69);
var outside = Sweep(new(1e8, 0, 0), new(-1e8, 0, 0));
Assert.True(outside.Collided);
Assert.InRange(outside.Position.RelativeTo(origin, Cell).X, ring.RadiusMeters + 50_001.69, ring.RadiusMeters + 50_001.72);
var wall = Sweep(new(ring.RadiusMeters - 1000, 0, 0), new(ring.RadiusMeters - 1000, 1e8, 0));
Assert.True(wall.Collided); Assert.InRange(wall.Position.RelativeTo(origin, Cell).Y, 249_998.28, 249_998.31);
Assert.True(Sweep(new(ring.RadiusMeters - 1.7, 0, 0), new(-1e8, 0, 0)).Collided);
var grazing = Sweep(new(ring.RadiusMeters - 1.71, 0, 0), new(ring.RadiusMeters - 1.71, 0, 1e8));
Assert.True(grazing.Collided);
Assert.InRange(PlanetSurface.Distance(ring, grazing.Position.RelativeTo(origin, Cell)), 1.69, 1.72);
}
[Fact]
public void TerrainAndTextureWrapAndMeshNormalsPointInward()
{
var ring = Ring() with { TerrainAmplitudeMeters = 120, Terrain = new() { NoiseFrequency = 3 } };
var a = RingWorldGeometry.Point(ring, 0, 1234); var b = RingWorldGeometry.Point(ring, System.Math.Tau, 1234);
Assert.InRange((a - b).Length, 0, 1e-7);
Assert.InRange((RingWorldGeometry.TextureDirection(ring, a) - RingWorldGeometry.TextureDirection(ring, b)).Length, 0, 1e-12);
var patch = TerrainPatchBuilder.Build(ring, new(0, 2, 1, 1));
for (var i = 0; i < 25 * 25; i++)
{
var offset = i * 9;
var p = patch.Origin + new DVector3(patch.Vertices[offset], patch.Vertices[offset + 1], patch.Vertices[offset + 2]);
var n = new DVector3(patch.Vertices[offset + 3], patch.Vertices[offset + 4], patch.Vertices[offset + 5]);
Assert.True(DVector3.Dot(n, -RingWorldGeometry.Radial(p)) > .9);
Assert.InRange(System.Math.Abs(RingWorldGeometry.Clearance(ring, p)), 0, .1);
}
}
[Fact]
public void CoarseTerrainSkirtsStayInsideTheMetalHull()
{
var ring = Ring();
for (var face = 0; face < 6; face++)
{
var patch = TerrainPatchBuilder.Build(ring, new(face, 0, 0, 0));
for (var i = 25 * 25 * 9; i < patch.Vertices.Length; i += 9)
{
var p = patch.Origin + new DVector3(patch.Vertices[i], patch.Vertices[i + 1], patch.Vertices[i + 2]);
Assert.True(new DVector3(p.X, 0, p.Z).Length < ring.RadiusMeters + ring.RingWorld!.ThicknessMeters);
}
}
}
[Fact]
public void PlayerFallsJumpsCrouchesAndWalksEntireInnerCircumference()
{
var ring = Ring(100); var system = new StarSystemData(new(0, 0, 0), 1, [], [ring]);
var world = new UniverseNeighborhood(system, []); var center = UniversePosition.AtCellCenter(system.Coordinate, Cell);
var player = new PlayerController(center.Add(new(94, 0, 0), Cell), new(0, 0, 1), new(-1, 0, 0), Cell, world);
for (var i = 0; i < 240; i++) player.Update(1d / 60, 0, 0, false, false, world);
Assert.True(player.Grounded);
Assert.InRange(PlanetSurface.Distance(ring, player.Position.RelativeTo(center, Cell)), 1.69, 1.72);
var turns = 0d; var last = 0d;
for (var i = 0; i < 9000; i++)
{
player.Update(1d / 60, 1, 0, false, false, world);
var p = player.Position.RelativeTo(center, Cell); var angle = System.Math.Atan2(p.Z, p.X);
turns += System.Math.Atan2(System.Math.Sin(angle - last), System.Math.Cos(angle - last)); last = angle;
Assert.InRange(PlanetSurface.Distance(ring, p), 1.69, 1.72);
Assert.True(DVector3.Dot(player.Up, -RingWorldGeometry.Radial(p)) > .9999);
}
Assert.True(turns > System.Math.Tau);
player.Update(1d / 60, 0, 0, true, false, world);
Assert.False(player.Grounded);
for (var i = 0; i < 20; i++) player.Update(1d / 60, 0, 0, false, false, world);
Assert.True(PlanetSurface.Distance(ring, player.Position.RelativeTo(center, Cell)) > 2);
for (var i = 0; i < 180; i++) player.Update(1d / 60, 0, 0, false, false, world);
Assert.True(player.Grounded);
player.Update(1d / 60, 0, 0, false, true, world);
Assert.InRange(PlanetSurface.Distance(ring, player.Position.RelativeTo(center, Cell)), .99, 1.01);
}
[Fact]
public void SurfaceFlightEntersThroughOpenCenterAndReachesInnerTerrain()
{
var planet = Ring() with { Orientation = new(35, 20) };
var star = new StarData(0, "Sun", new(1e11, 1e11, 0), 7e8, 1, Vector3.One, 1);
var system = new StarSystemData(new(0, 0, 0), 1, [star], [planet]);
var world = new UniverseNeighborhood(system, []); var body = UniversePosition.AtCellCenter(system.Coordinate, Cell);
var overview = planet.Orientation.ToWorld(new DVector3(1, 1.35, .65).Normalized() * planet.RadiusMeters * 3);
var camera = new FlyCamera(body.Add(overview, Cell), Cell);
camera.RequestSurfaceView([system]);
for (var i = 0; i < 400 && camera.IsFocusing; i++) camera.Update(.05, DVector3.Zero, world);
Assert.False(camera.IsFocusing);
var target = planet.Orientation.ToWorld(FlyCamera.RingInteriorPoint(system, planet));
Assert.InRange((camera.Position.RelativeTo(body, Cell) - target).Length, 0, .001);
Assert.InRange(PlanetSurface.Distance(planet, camera.Position.RelativeTo(body, Cell)), 999.99, 1000.01);
Assert.True(DVector3.Dot(camera.Up, planet.Orientation.ToWorld(-RingWorldGeometry.Radial(planet.Orientation.ToBody(target)))) > .98);
camera.RequestFocus([system], planet.Id);
for (var i = 0; i < 400 && camera.IsFocusing; i++) camera.Update(.05, DVector3.Zero, world);
Assert.False(camera.IsFocusing);
Assert.True(camera.Position.RelativeTo(body, Cell).Length > planet.MaximumTerrainRadiusMeters * 2);
}
[Fact]
public void VegetationIsDeterministicFacesInwardAndDoesNotGrowInWater()
{
var planet = Ring() with { Vegetation = new() { DensityMultiplier = 1 },
SurfaceTexture = new(2, 2, Enumerable.Repeat(new byte[] { 45, 80, 35, 255 }, 4).SelectMany(p => p).ToArray()) };
var catalog = new VegetationCatalog
{
Biomes = [new("forest", [45, 80, 35], 24, "forest"), new("dry", [145, 150, 80], 18, "dry"),
new("sand", [205, 181, 120], 15, "sand"), new("snow", [240, 242, 245], 12, "snow")],
Plants = [new() { Id = "tree", Model = "tree.glb", Type = VegetationType.Tree, MatchColorRgb = [45, 80, 35], MinimumSpacingMeters = 2 }]
};
var generator = new VegetationTileGenerator(new(planet, catalog));
var keys = generator.Around(new(planet.RadiusMeters - 1.7, 0, 0), 0, 300).ToArray();
Assert.Contains(keys, k => k.X == 0); Assert.Contains(keys, k => k.X > 60_000);
var tiles = keys.Select(k => generator.Generate(k)).ToArray();
var instances = tiles.SelectMany(t => t.Instances).ToArray(); Assert.NotEmpty(instances);
foreach (var tile in tiles) Assert.Equal(tile.Instances, generator.Generate(tile.Key).Instances);
foreach (var instance in instances)
{
Assert.True(DVector3.Dot(instance.Up, -RingWorldGeometry.Radial(instance.BodyPosition)) > .99999);
Assert.InRange(RingWorldGeometry.Clearance(planet, instance.BodyPosition), -.221, -.219);
}
var wet = new VegetationTileGenerator(new(planet with { OceanMask = new(2, 2, [1, 1, 1, 1]) }, catalog));
foreach (var key in keys) Assert.Empty(wet.Generate(key).Instances);
}
[Fact]
public void SlopedGroundContactKeepsEyeHeightAboveTheSameRenderedSurface()
{
var planet = Ring(10_000) with { Terrain = new()
{ HeightMap = new(2, 2, [0, 0, 1, 1]), HeightmapMinMeters = 0, HeightmapMaxMeters = 500 } };
var ground = RingWorldGeometry.Point(planet, .37, 350);
var normal = RingWorldGeometry.Contact(planet, ground).Up;
var contact = RingWorldGeometry.Contact(planet, ground + normal * 1.7);
Assert.InRange(contact.Clearance, 1.68, 1.72);
Assert.InRange(System.Math.Abs(RingWorldGeometry.Clearance(planet, contact.Point)), 0, .001);
var system = new StarSystemData(new(0, 0, 0), 1, [], [planet]); var world = new UniverseNeighborhood(system, []);
var center = UniversePosition.AtCellCenter(system.Coordinate, Cell);
var player = new PlayerController(center.Add(ground + normal * 1.7, Cell), new(0, 1, 0), normal, Cell, world);
for (var i = 0; i < 120; i++) player.Update(1d / 60, .1, 0, false, false, world);
Assert.True(player.Grounded);
Assert.InRange(PlanetSurface.Distance(planet, player.Position.RelativeTo(center, Cell)), 1.67, 1.73);
}
[Theory]
[InlineData(0, 350)]
[InlineData(2.1, -350)]
[InlineData(6.28, 150)]
public void SlopesDoNotTiltThePlayerWhileStandingWalkingCrouchingOrJumping(double angle, double across)
{
var planet = Ring(10_000) with { Orientation = new(37, 121, 19), Terrain = new()
{ HeightMap = new(2, 2, [0, 0, 1, 1]), HeightmapMinMeters = 0, HeightmapMaxMeters = 500 } };
var ground = RingWorldGeometry.Point(planet, angle, across);
var normal = RingWorldGeometry.Contact(planet, ground).Up;
var local = ground + normal * 1.7;
var system = new StarSystemData(new(0, 0, 0), 1, [], [planet]);
var world = new UniverseNeighborhood(system, []); var center = UniversePosition.AtCellCenter(system.Coordinate, Cell);
var player = new PlayerController(center.Add(planet.Orientation.ToWorld(local), Cell),
planet.Orientation.ToWorld(new(0, 1, 0)), planet.Orientation.ToWorld(normal), Cell, world);
void AssertUpright()
{
var p = planet.Orientation.ToBody(player.Position.RelativeTo(center, Cell));
var up = planet.Orientation.ToBody(player.Up);
Assert.InRange((up + RingWorldGeometry.Radial(p)).Length, 0, 1e-9);
// A widthwise slope must not introduce camera pitch or roll toward either side wall.
Assert.InRange(System.Math.Abs(up.Y), 0, 1e-10);
Assert.InRange(System.Math.Abs(DVector3.Dot(player.Forward, player.Up)), 0, 1e-9);
}
Assert.True(System.Math.Abs(normal.Y) > .2); // This is a real slope, not flat ground.
AssertUpright();
for (var i = 0; i < 60; i++) { player.Update(1d / 60, 0, 0, false, false, world); AssertUpright(); }
Assert.True(player.Grounded);
var beforeWalk = player.Position.RelativeTo(center, Cell);
for (var i = 0; i < 120; i++) { player.Update(1d / 60, .5, 0, false, false, world); AssertUpright(); }
Assert.True(player.Grounded);
Assert.True((player.Position.RelativeTo(center, Cell) - beforeWalk).Length > 3);
player.Update(1d / 60, 0, 0, false, true, world); AssertUpright();
Assert.True(player.Crouching);
Assert.InRange(PlanetSurface.Distance(planet, player.Position.RelativeTo(center, Cell)), .98, 1.02);
player.Update(1d / 60, 0, 0, false, false, world);
var jumpStart = planet.Orientation.ToBody(player.Position.RelativeTo(center, Cell));
player.Update(1d / 60, 0, 0, true, false, world); AssertUpright();
Assert.False(player.Grounded);
for (var i = 0; i < 20; i++) { player.Update(1d / 60, 0, 0, false, false, world); AssertUpright(); }
var airborne = planet.Orientation.ToBody(player.Position.RelativeTo(center, Cell));
// Cell coordinates are billions of metres; allow sub-millimetre roundoff when
// transforming the world-space position back to the tilted body's width axis.
Assert.InRange(System.Math.Abs(airborne.Y - jumpStart.Y), 0, 1e-4);
Assert.True(new DVector3(airborne.X, 0, airborne.Z).Length < new DVector3(jumpStart.X, 0, jumpStart.Z).Length - .5);
for (var i = 0; i < 180; i++) { player.Update(1d / 60, 0, 0, false, false, world); AssertUpright(); }
Assert.True(player.Grounded);
Assert.InRange(PlanetSurface.Distance(planet, player.Position.RelativeTo(center, Cell)), 1.67, 1.73);
}
[Fact]
public void NearbySideWallDoesNotTiltGroundOrPullThePlayerAcrossTheRing()
{
var planet = Ring() with { Orientation = new(35, 73) };
var system = new StarSystemData(new(0, 0, 0), 1, [], [planet]); var world = new UniverseNeighborhood(system, []);
var center = UniversePosition.AtCellCenter(system.Coordinate, Cell);
var start = new DVector3(planet.RadiusMeters - 1000, planet.RingWorld!.WidthMeters * .5 - 1.7, 0);
var player = new PlayerController(center.Add(planet.Orientation.ToWorld(start), Cell),
planet.Orientation.ToWorld(new(0, 0, 1)), planet.Orientation.ToWorld(new(0, -1, 0)), Cell, world);
Assert.InRange((planet.Orientation.ToBody(player.Up) - new DVector3(-1, 0, 0)).Length, 0, 1e-10);
for (var i = 0; i < 120; i++)
{
player.Update(1d / 60, 0, 0, false, false, world);
Assert.False(player.Grounded);
Assert.InRange(System.Math.Abs(planet.Orientation.ToBody(player.Up).Y), 0, 1e-10);
}
var end = planet.Orientation.ToBody(player.Position.RelativeTo(center, Cell));
Assert.True(end.X > start.X + 10); // Falls toward the deck, not toward the wall.
Assert.InRange(System.Math.Abs(end.Y - start.Y), 0, .02); // Only collision separation is permitted.
}
}