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

@@ -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.
}
}