init
This commit is contained in:
78
tests/Universe.Tests/AsteroidAndGlowTests.cs
Normal file
78
tests/Universe.Tests/AsteroidAndGlowTests.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json.Nodes;
|
||||
using Universe.Core.Configuration;
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class AsteroidAndGlowTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(6)]
|
||||
public void GeodesicLodsAreClosedUnitSpheresWithIncreasingGeometry(int subdivision)
|
||||
{
|
||||
var mesh = IcosphereMesh.Build(subdivision); var vertices = mesh.Vertices.Length / 6;
|
||||
Assert.Equal(10 * (int)System.Math.Pow(4, subdivision) + 2, vertices);
|
||||
Assert.Equal(20 * (int)System.Math.Pow(4, subdivision), mesh.Indices.Length / 3);
|
||||
var edges = new Dictionary<ulong, int>();
|
||||
for (var i = 0; i < vertices; i++)
|
||||
Assert.InRange(new Vector3(mesh.Vertices[i * 6], mesh.Vertices[i * 6 + 1], mesh.Vertices[i * 6 + 2]).Length(), .99999f, 1.00001f);
|
||||
void Edge(uint a, uint b)
|
||||
{ var key = ((ulong)System.Math.Min(a, b) << 32) | System.Math.Max(a, b); edges[key] = edges.GetValueOrDefault(key) + 1; }
|
||||
for (var i = 0; i < mesh.Indices.Length; i += 3)
|
||||
{
|
||||
var a = mesh.Indices[i]; var b = mesh.Indices[i + 1]; var c = mesh.Indices[i + 2];
|
||||
Assert.InRange(a, 0u, (uint)vertices - 1); Assert.NotEqual(a, b); Assert.NotEqual(a, c);
|
||||
Edge(a, b); Edge(b, c); Edge(c, a);
|
||||
}
|
||||
Assert.All(edges.Values, count => Assert.Equal(2, count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RockDetailIncreasesWithProjectedSizeAndRespectsDistanceLimit()
|
||||
{
|
||||
var settings = new AsteroidDetailSettings();
|
||||
var levels = new[] { 5d, 20, 50, 100, 300, 1000 }.Select(p => AsteroidLod.Select(p, 5, settings)).ToArray();
|
||||
Assert.Equal(levels.OrderBy(x => x), levels);
|
||||
Assert.Equal(settings.BaseSubdivision, levels[0]); Assert.Equal(settings.MaxSubdivision, levels[^1]);
|
||||
Assert.InRange(AsteroidLod.Select(1000, 1000, settings), settings.BaseSubdivision, 3);
|
||||
Assert.Equal(4, AsteroidLod.Select(1e6, 1, settings with { MaxSubdivision = 4 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlareFadesContinuouslyAndRemainsVisibleAtOuterPlanets()
|
||||
{
|
||||
var settings = new StarGlowSettings(); var au = UniverseScale.AstronomicalUnitMeters;
|
||||
var samples = new[] { .1, 1, 5, 10, 30, 50, 100 }.Select(d => settings.Attenuation(d * au, 1)).ToArray();
|
||||
Assert.Equal(samples.OrderByDescending(x => x), samples);
|
||||
Assert.True(samples[4] > .1); Assert.True(samples[^1] > 0);
|
||||
Assert.Equal(settings.Attenuation(au, 1) * 2, settings.Attenuation(au, 4), 10);
|
||||
Assert.Equal(0, (settings with { Enabled = false }).Attenuation(au, 1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("subdivision")]
|
||||
[InlineData("budget")]
|
||||
[InlineData("noise")]
|
||||
[InlineData("glowFalloff")]
|
||||
[InlineData("rays")]
|
||||
public void CatalogRejectsInvalidAsteroidAndGlowSettings(string invalid)
|
||||
{
|
||||
var catalogPath = Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json");
|
||||
var json = JsonNode.Parse(File.ReadAllText(catalogPath))!;
|
||||
var system = json["systems"]![0]!;
|
||||
var asteroid = system["planets"]!.AsArray().Single(p => p!["id"]!.GetValue<string>() == "saturn")!["rings"]!["rocks"]!["asteroid"]!;
|
||||
var glow = system["stars"]![0]!["glow"]!;
|
||||
if (invalid == "subdivision") asteroid["maxSubdivision"] = 10;
|
||||
if (invalid == "budget") asteroid["maxHighDetailInstances"] = 10000;
|
||||
if (invalid == "noise") asteroid["noiseOctaves"] = 0;
|
||||
if (invalid == "glowFalloff") glow["distanceFalloff"] = -1;
|
||||
if (invalid == "rays") glow["rayCount"] = 10000;
|
||||
var path = Path.Combine(Path.GetDirectoryName(catalogPath)!, Guid.NewGuid() + ".json");
|
||||
try { File.WriteAllText(path, json.ToJsonString()); Assert.Throws<InvalidDataException>(() => KnownSolarSystemCatalog.Load(path)); }
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
}
|
||||
89
tests/Universe.Tests/CloudTests.cs
Normal file
89
tests/Universe.Tests/CloudTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Numerics;
|
||||
using System.Text.Json.Nodes;
|
||||
using Universe.Core.Configuration;
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
using Universe.App.Runtime;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class CloudTests
|
||||
{
|
||||
[Fact]
|
||||
public void AtmosphereControlsRepeatableCloudCoverageWithoutCloudsInVacuum()
|
||||
{
|
||||
Assert.Null(CloudSettings.ForAtmosphere(8, 0, 1, PlanetSurfaceType.Rocky));
|
||||
Assert.Null(CloudSettings.ForAtmosphere(8, 100000, .02, PlanetSurfaceType.Rocky));
|
||||
Assert.Null(CloudSettings.ForAtmosphere(8, 100000, 3, PlanetSurfaceType.GasGiant));
|
||||
var thin = CloudSettings.ForAtmosphere(8, 100000, .15, PlanetSurfaceType.Terrestrial)!;
|
||||
var thick = CloudSettings.ForAtmosphere(8, 100000, 1.5, PlanetSurfaceType.Terrestrial)!;
|
||||
Assert.Equal(thick, CloudSettings.ForAtmosphere(8, 100000, 1.5, PlanetSurfaceType.Terrestrial));
|
||||
Assert.True(thick.Coverage > thin.Coverage); Assert.True(thick.Density > thin.Density);
|
||||
Assert.InRange(thick.BaseAltitudeMeters + thick.ThicknessMeters, 100, 100000);
|
||||
Assert.NotEqual(thick, CloudSettings.ForAtmosphere(9, 100000, 1.5, PlanetSurfaceType.Terrestrial));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudNoiseWrapsSeamlesslyAndContainsVolumeStructure()
|
||||
{
|
||||
const double x = -.123, y = .217, z = 1.835;
|
||||
Assert.Equal(CloudNoise.Value(x, y, z, 8), CloudNoise.Value(x + 1, y - 2, z + 3, 8), 10);
|
||||
Assert.Equal(CloudNoise.Cellular(x, y, z, 8), CloudNoise.Cellular(x + 1, y - 2, z + 3, 8), 10);
|
||||
var data = CloudNoise.Build(16, false);
|
||||
var values = data.Where((_, i) => i % 4 == 0).ToArray();
|
||||
Assert.True(values.Max() - values.Min() > 60);
|
||||
Assert.Equal(data, CloudNoise.Build(16, false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowSunLosesMoreBlueThanRedAndThinAtmosphereWarmsLess()
|
||||
{
|
||||
var color = new Vector3(.13f, .32f, 1);
|
||||
Vector3 Light(double mu, double density) => CloudLighting.SunTransmission(mu, 3000, 160000, 6371000, density, color, 1.4);
|
||||
var day = Light(.8, 1); var sunset = Light(.01, 1); var thin = Light(.01, .1);
|
||||
Assert.True(sunset.X / sunset.Z > day.X / day.Z * 1.5);
|
||||
Assert.True(sunset.X > sunset.Z); Assert.True(sunset.Z < day.Z);
|
||||
Assert.True(thin.X / thin.Z < sunset.X / sunset.Z);
|
||||
Assert.InRange(sunset.Z, 0, 1);
|
||||
var dusty = CloudLighting.SunTransmission(.01, 3000, 160000, 6371000, 1, new(.8f, .48f, .25f), 1.4);
|
||||
Assert.True(dusty.X > dusty.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudFlightLeavesTheOceanAndReachesTheCloudTops()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json");
|
||||
var sol = KnownSolarSystemCatalog.Load(path).GetById("sol");
|
||||
var earth = sol.Planets.Single(p => p.Id == "earth");
|
||||
var size = UniverseScale.DefaultCellSizeMeters;
|
||||
var origin = UniversePosition.AtCellCenter(sol.Coordinate, size);
|
||||
var neighborhood = new UniverseNeighborhood(sol, []);
|
||||
var camera = new FlyCamera(origin, size);
|
||||
camera.SetInitialView(sol, new StartupConfiguration()); camera.RequestOceanView([sol]);
|
||||
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||
camera.RequestCloudView([sol]);
|
||||
for (var i = 0; i < 400; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||
var altitude = (camera.Position.RelativeTo(origin, size) - earth.PositionMeters).Length - earth.RadiusMeters;
|
||||
Assert.False(camera.IsFocusing);
|
||||
Assert.Equal(earth.Clouds!.BaseAltitudeMeters + earth.Clouds.ThicknessMeters + 500, altitude, 1);
|
||||
Assert.Equal(100, camera.SpeedMetersPerSecond);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("outsideAtmosphere")]
|
||||
[InlineData("negativeDensity")]
|
||||
[InlineData("excessiveSteps")]
|
||||
public void InvalidCloudLayersAreRejected(string problem)
|
||||
{
|
||||
var catalog = Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json");
|
||||
var json = JsonNode.Parse(File.ReadAllText(catalog))!;
|
||||
var earth = json["systems"]![0]!["planets"]!.AsArray().Single(p => p!["id"]!.GetValue<string>() == "earth")!;
|
||||
if (problem == "outsideAtmosphere") earth["clouds"]!["thicknessMeters"] = 500000;
|
||||
if (problem == "negativeDensity") earth["clouds"]!["density"] = -1;
|
||||
if (problem == "excessiveSteps") earth["clouds"]!["viewSteps"] = 10000;
|
||||
var path = Path.Combine(Path.GetDirectoryName(catalog)!, Guid.NewGuid() + ".json");
|
||||
try { File.WriteAllText(path, json.ToJsonString()); Assert.Throws<InvalidDataException>(() => KnownSolarSystemCatalog.Load(path)); }
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
}
|
||||
114
tests/Universe.Tests/GasAndRingTests.cs
Normal file
114
tests/Universe.Tests/GasAndRingTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Nodes;
|
||||
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 GasAndRingTests
|
||||
{
|
||||
private static string CatalogPath => Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json");
|
||||
private static StarSystemData Sol() => KnownSolarSystemCatalog.Load(CatalogPath).GetById("sol");
|
||||
private static PlanetData Saturn() => Sol().Planets.Single(p => p.Id == "saturn");
|
||||
|
||||
[Theory]
|
||||
[InlineData(4, -4)]
|
||||
[InlineData(.5, -.5)]
|
||||
public void GasVolumesHaveNoSolidCollisionBoundary(double from, double to)
|
||||
{
|
||||
var saturn = Saturn(); var size = UniverseScale.DefaultCellSizeMeters;
|
||||
var origin = UniversePosition.AtCellCenter(new(0, 0, 0), size);
|
||||
var start = origin.Add(saturn.PositionMeters + new DVector3(from * saturn.RadiusMeters, 0, 0), size);
|
||||
var end = origin.Add(saturn.PositionMeters + new DVector3(to * saturn.RadiusMeters, 0, 0), size);
|
||||
var collision = PlanetCollisionSolver.Sweep(start, end, origin, [saturn], size);
|
||||
Assert.False(collision.Collided); Assert.Equal(end, collision.Position);
|
||||
Assert.True(saturn.IsGasPlanet);
|
||||
Assert.False(Sol().Planets.Single(p => p.Id == "earth").IsGasPlanet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RadialMaterialIsRepeatableAndAllNamedGapsAreEmpty()
|
||||
{
|
||||
var rings = Saturn().Rings!; var profile = new RingProfile(rings);
|
||||
Assert.Equal(profile.Rgba, new RingProfile(rings).Rgba);
|
||||
Assert.NotEqual(profile.Rgba, new RingProfile(rings with { Seed = rings.Seed + 1 }).Rgba);
|
||||
Assert.Equal(0, profile.Sample(rings.InnerRadiusMeters - 1).OpticalDepth);
|
||||
Assert.Equal(0, profile.Sample(rings.OuterRadiusMeters + 1).OpticalDepth);
|
||||
Assert.Equal(0, profile.Sample((rings.GapInnerRadiusMeters + rings.GapOuterRadiusMeters) * .5).OpticalDepth);
|
||||
foreach (var gap in rings.Gaps) Assert.Equal(0, profile.Sample((gap.InnerRadiusMeters + gap.OuterRadiusMeters) * .5).OpticalDepth);
|
||||
Assert.True(profile.Sample(100000000).OpticalDepth > profile.Sample(80000000).OpticalDepth);
|
||||
var depths = Enumerable.Range(0, 100).Select(i => profile.Sample(100000000 + i * 12000).OpticalDepth).ToArray();
|
||||
Assert.True(depths.Max() - depths.Min() > .1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RocksAreStableBoundedAndNeverSpawnInGaps()
|
||||
{
|
||||
var rings = Saturn().Rings!;
|
||||
rings = rings with { Rocks = rings.Rocks with { MaximumInstances = 2000, StreamRadiusMeters = 800, FadeStartMeters = 600 } };
|
||||
var profile = new RingProfile(rings); var stream = new RingRockStreamer(profile);
|
||||
var cell = new RingCell((long)(100000000 / rings.Rocks.CellSizeMeters), 0);
|
||||
Assert.Equal(stream.GenerateCell(cell), new RingRockStreamer(profile).GenerateCell(cell));
|
||||
Assert.NotEmpty(stream.GenerateCell(cell));
|
||||
var position = new DVector3(100000000, 10, 0);
|
||||
var first = stream.Update(position);
|
||||
Assert.InRange(first.Rocks.Count, 1, 2000);
|
||||
Assert.Same(first, stream.Update(position + new DVector3(1, 0, 1)));
|
||||
var second = stream.Update(position + new DVector3(256, 0, 0));
|
||||
var overlap = first.Rocks.Intersect(second.Rocks).ToArray();
|
||||
Assert.NotEmpty(overlap);
|
||||
Assert.All(second.Rocks, rock =>
|
||||
{
|
||||
Assert.InRange(rock.Radius, rings.Rocks.MinRadiusMeters, rings.Rocks.MaxRadiusMeters);
|
||||
Assert.InRange(System.Math.Abs(rock.Position.Y), 0, rings.ThicknessMeters * .5);
|
||||
Assert.True(profile.Sample(System.Math.Sqrt(rock.Position.X * rock.Position.X + rock.Position.Z * rock.Position.Z)).OpticalDepth > 0);
|
||||
});
|
||||
var gapCell = new RingCell((long)(120000000 / rings.Rocks.CellSizeMeters), 0);
|
||||
Assert.Empty(stream.GenerateCell(gapCell));
|
||||
for (var i = 0; i < 50; i++) stream.Update(position + new DVector3(i * 10000, 0, 0));
|
||||
Assert.InRange(stream.CachedCells, 1, 150);
|
||||
Assert.Empty(stream.Update(position + new DVector3(0, 10000, 0)).Rocks);
|
||||
Assert.Equal(0, stream.CachedCells);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gasSteps")]
|
||||
[InlineData("gasTerrain")]
|
||||
[InlineData("rockBudget")]
|
||||
[InlineData("ringInterval")]
|
||||
[InlineData("streamRadius")]
|
||||
public void RejectsUnsafeOrInconsistentVisualConfiguration(string invalid)
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(CatalogPath))!;
|
||||
var saturn = json["systems"]![0]!["planets"]!.AsArray().Single(p => p!["id"]!.GetValue<string>() == "saturn")!;
|
||||
if (invalid == "gasSteps") saturn["gas"]!["viewSteps"] = 0;
|
||||
if (invalid == "gasTerrain") saturn["terrain"]!["proceduralNoiseAmplitudeMeters"] = 100;
|
||||
if (invalid == "rockBudget") saturn["rings"]!["rocks"]!["maximumInstances"] = 10000000;
|
||||
if (invalid == "ringInterval") saturn["rings"]!["bands"]![0]!["outerRadiusMeters"] = 1;
|
||||
if (invalid == "streamRadius") saturn["rings"]!["rocks"]!["streamRadiusMeters"] = 10000000;
|
||||
var path = Path.Combine(Path.GetDirectoryName(CatalogPath)!, Guid.NewGuid() + ".json");
|
||||
try { File.WriteAllText(path, json.ToJsonString()); Assert.Throws<InvalidDataException>(() => KnownSolarSystemCatalog.Load(path)); }
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RingFlightLeavesEarthAndArrivesAtConfiguredRingView()
|
||||
{
|
||||
var catalog = KnownSolarSystemCatalog.Load(CatalogPath); var sol = catalog.GetById("sol");
|
||||
var generator = new UniverseGenerator(1, knownSystems: catalog); var neighborhood = new UniverseStreamer(generator).Load(sol.Coordinate);
|
||||
var camera = new FlyCamera(UniversePosition.AtCellCenter(sol.Coordinate, generator.CellSizeMeters), generator.CellSizeMeters);
|
||||
camera.SetInitialView(sol, new StartupConfiguration());
|
||||
camera.RequestRingView([sol]);
|
||||
for (var i = 0; i < 900; i++) camera.Update(1d / 60, DVector3.Zero, neighborhood);
|
||||
Assert.False(camera.IsFocusing);
|
||||
var saturn = sol.Planets.Single(p => p.Id == "saturn"); var rings = saturn.Rings!;
|
||||
var body = saturn.Orientation.ToBody(camera.Position.RelativeTo(UniversePosition.AtCellCenter(sol.Coordinate, generator.CellSizeMeters), generator.CellSizeMeters) - saturn.PositionMeters);
|
||||
Assert.InRange(System.Math.Abs(body.Y - rings.Preview.HeightMeters), 0, .01);
|
||||
Assert.InRange(System.Math.Abs(body.Length - (rings.InnerRadiusMeters + (rings.OuterRadiusMeters - rings.InnerRadiusMeters) * rings.Preview.RadiusFraction)), 0, .01);
|
||||
Assert.Equal(rings.Preview.FieldOfViewDegrees, camera.FieldOfViewRadians * 180 / System.Math.PI, 6);
|
||||
Assert.Equal(rings.Preview.SpeedMetersPerSecond, camera.SpeedMetersPerSecond);
|
||||
}
|
||||
}
|
||||
164
tests/Universe.Tests/KnownSolarSystemTests.cs
Normal file
164
tests/Universe.Tests/KnownSolarSystemTests.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Text.Json.Nodes;
|
||||
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 KnownSolarSystemTests
|
||||
{
|
||||
private static string CatalogPath => Path.Combine(AppContext.BaseDirectory, "KnownSolarSystem", "solar-systems.json");
|
||||
private static KnownSolarSystemCatalog Load() => KnownSolarSystemCatalog.Load(CatalogPath);
|
||||
|
||||
[Fact]
|
||||
public void SolarSystemContainsSunAndEightPlanetsInOrder()
|
||||
{
|
||||
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));
|
||||
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.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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigurationStartsAtEarthAndCanSelectAnotherPlanetById()
|
||||
{
|
||||
var config = EngineConfiguration.Load(Path.Combine(AppContext.BaseDirectory, "config.json"));
|
||||
Assert.Equal("earth", config.ResolveStartup(Load()).Planet.Id);
|
||||
var modified = config with { Startup = config.Startup with { PlanetId = "saturn" } };
|
||||
Assert.Equal("Saturnus", modified.ResolveStartup(Load()).Planet.Name);
|
||||
var invalid = config with { Startup = config.Startup with { PlanetId = "not-a-planet" } };
|
||||
Assert.Throws<InvalidDataException>(() => invalid.ResolveStartup(Load()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownSystemOverridesOnlyItsAssignedSector()
|
||||
{
|
||||
var catalog = Load();
|
||||
var first = new UniverseGenerator(1, knownSystems: catalog);
|
||||
var second = new UniverseGenerator(999, knownSystems: catalog);
|
||||
Assert.Same(catalog.GetById("sol"), first.Generate(new(0, 0, 0)));
|
||||
Assert.Same(first.Generate(new(0, 0, 0)), second.Generate(new(0, 0, 0)));
|
||||
Assert.StartsWith("procedural:", first.Generate(new(1, 0, 0)).Id);
|
||||
Assert.NotEqual(first.Generate(new(1, 0, 0)).Seed, second.Generate(new(1, 0, 0)).Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarthIdStillSelectsEarthAfterReorderingTheCatalog()
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(CatalogPath))!;
|
||||
var planets = json["systems"]![0]!["planets"]!.AsArray();
|
||||
var earth = planets[2]!.DeepClone();
|
||||
planets.RemoveAt(2);
|
||||
planets.Insert(0, earth);
|
||||
var temporary = Path.Combine(Path.GetDirectoryName(CatalogPath)!, "test-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, json.ToJsonString());
|
||||
var config = EngineConfiguration.Load(Path.Combine(AppContext.BaseDirectory, "config.json"));
|
||||
var target = config.ResolveStartup(KnownSolarSystemCatalog.Load(temporary));
|
||||
Assert.Equal("earth", target.Planet.Id);
|
||||
Assert.Equal("Jorden", target.Planet.Name);
|
||||
Assert.Equal(0, target.Planet.Index);
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EarthLoadsBothUserSuppliedMaps()
|
||||
{
|
||||
var earth = Load().GetById("sol").Planets.Single(p => p.Id == "earth");
|
||||
Assert.NotNull(earth.SurfaceTexture);
|
||||
Assert.NotNull(earth.Terrain.HeightMap);
|
||||
Assert.True(earth.SurfaceTexture.Width > 1);
|
||||
Assert.True(earth.Terrain.HeightMap.Width > 1);
|
||||
Assert.Contains(earth.Terrain.HeightMap.Samples, v => v > 0.8);
|
||||
Assert.Contains(earth.Terrain.HeightMap.Samples, v => v < 0.02);
|
||||
Assert.True(earth.Terrain.NoiseFrequency > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeightmapWrapsLongitudeAndClampsThePoles()
|
||||
{
|
||||
var map = new HeightMap(4, 2, [0, 0.2f, 0.4f, 0.6f, 0.4f, 0.6f, 0.8f, 1]);
|
||||
Assert.Equal(map.SampleUv(-0.001, 0.5), map.SampleUv(0.999, 0.5), 6);
|
||||
Assert.Equal(map.SampleUv(0.25, 0), map.SampleUv(0.25, -5));
|
||||
Assert.Equal(map.SampleUv(0.25, 1), map.SampleUv(0.25, 5));
|
||||
var orientation = new PlanetOrientation(97.8, 39);
|
||||
var d = new DVector3(1, 0.2, -0.8).Normalized();
|
||||
Assert.InRange((orientation.ToBody(orientation.ToWorld(d)) - d).Length, 0, 1e-12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeightmapAndNoiseBothContributeWhileBlackOceanRemainsFlat()
|
||||
{
|
||||
var earth = Load().GetById("sol").Planets.Single(p => p.Id == "earth");
|
||||
var flatMap = new HeightMap(2, 2, [0.5f, 0.5f, 0.5f, 0.5f]);
|
||||
var basePlanet = earth with
|
||||
{
|
||||
Ocean = null,
|
||||
TerrainAmplitudeMeters = 0,
|
||||
Terrain = earth.Terrain with
|
||||
{ HeightMap = flatMap, HeightmapMinMeters = 0, HeightmapMaxMeters = 1000, NoiseMask = TerrainNoiseMask.Everywhere }
|
||||
};
|
||||
var noisyPlanet = basePlanet with { TerrainAmplitudeMeters = 200 };
|
||||
var differences = Enumerable.Range(0, 20).Select(i =>
|
||||
{
|
||||
var direction = new DVector3(System.Math.Cos(i), 0.3, System.Math.Sin(i)).Normalized();
|
||||
var baseline = TerrainHeight.Sample(basePlanet, direction);
|
||||
Assert.InRange(System.Math.Abs(baseline - earth.RadiusMeters - 500), 0, 1e-6);
|
||||
return TerrainHeight.Sample(noisyPlanet, direction) - baseline;
|
||||
}).ToArray();
|
||||
Assert.Contains(differences, delta => System.Math.Abs(delta) > 1);
|
||||
Assert.All(differences, delta => Assert.InRange(delta, -240, 240));
|
||||
var ocean = noisyPlanet with { Terrain = earth.Terrain with { HeightMap = new(2, 2, [0, 0, 0, 0]) } };
|
||||
Assert.Equal(earth.RadiusMeters, TerrainHeight.Sample(ocean, new(1, 0.3, 0.2)), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollisionStopsAtEarthHeightmapMountains()
|
||||
{
|
||||
var sol = Load().GetById("sol");
|
||||
var earth = sol.Planets.Single(p => p.Id == "earth");
|
||||
var map = earth.Terrain.HeightMap!;
|
||||
var maximum = Array.IndexOf(map.Samples, map.Samples.Max());
|
||||
var longitude = ((maximum % map.Width + 0.5) / map.Width - 0.5) * System.Math.Tau;
|
||||
var latitude = System.Math.PI / 2 - (maximum / map.Width + 0.5) / map.Height * System.Math.PI;
|
||||
var d = earth.Orientation.ToWorld(new(System.Math.Cos(latitude) * System.Math.Cos(longitude), System.Math.Sin(latitude), System.Math.Cos(latitude) * System.Math.Sin(longitude)));
|
||||
var center = UniversePosition.AtCellCenter(sol.Coordinate, UniverseScale.DefaultCellSizeMeters);
|
||||
var start = center.Add(earth.PositionMeters + d * (earth.MaximumTerrainRadiusMeters + 10000), UniverseScale.DefaultCellSizeMeters);
|
||||
var end = center.Add(earth.PositionMeters + d * (earth.RadiusMeters - 10000), UniverseScale.DefaultCellSizeMeters);
|
||||
var result = PlanetCollisionSolver.Sweep(start, end, center, [earth], UniverseScale.DefaultCellSizeMeters);
|
||||
Assert.True(result.Collided);
|
||||
var relative = result.Position.RelativeTo(center, UniverseScale.DefaultCellSizeMeters) - earth.PositionMeters;
|
||||
Assert.InRange(relative.Length - TerrainHeight.Sample(earth, relative), 1.3, 1.7);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("traversal")]
|
||||
[InlineData("rings")]
|
||||
public void RejectsInvalidCatalogDefinitions(string invalid)
|
||||
{
|
||||
var json = JsonNode.Parse(File.ReadAllText(CatalogPath))!;
|
||||
var planets = json["systems"]![0]!["planets"]!;
|
||||
if (invalid == "duplicate") planets[1]!["id"] = "mercury";
|
||||
if (invalid == "traversal") planets[2]!["surfaceTexture"] = "../config.json";
|
||||
if (invalid == "rings") planets[5]!["rings"]!["innerRadiusMeters"] = 1;
|
||||
var temporary = Path.Combine(Path.GetDirectoryName(CatalogPath)!, "test-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temporary, json.ToJsonString());
|
||||
Assert.Throws<InvalidDataException>(() => KnownSolarSystemCatalog.Load(temporary));
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
}
|
||||
}
|
||||
61
tests/Universe.Tests/OceanTests.cs
Normal file
61
tests/Universe.Tests/OceanTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Numerics;
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.Physics;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class OceanTests
|
||||
{
|
||||
[Fact]
|
||||
public void WaterMaskDistinguishesBlueWaterFromLandAndIce()
|
||||
{
|
||||
byte[] row = [25, 55, 120, 255, 35, 105, 45, 255, 230, 235, 240, 255, 160, 120, 70, 255];
|
||||
var pixels = Enumerable.Range(0, 32 * 8).SelectMany(i => row.Skip(i % 32 / 8 * 4).Take(4)).ToArray();
|
||||
var image = new RasterImage(32, 8, pixels);
|
||||
var mask = OceanSurface.CreateMask(image);
|
||||
Assert.Equal(1, mask.SampleUv(.125, .25), 6);
|
||||
Assert.Equal(0, mask.SampleUv(.375, .25), 6);
|
||||
Assert.Equal(0, mask.SampleUv(.625, .25), 6);
|
||||
Assert.Equal(0, mask.SampleUv(.875, .25), 6);
|
||||
Assert.Equal(mask.SampleUv(.01, .5), mask.SampleUv(1.01, .5), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OceanCarvesSeafloorAndCollisionAllowsSwimmingAboveIt()
|
||||
{
|
||||
var planet = new PlanetData(0, "Ocean", DVector3.Zero, 6371000, 0, 0, Vector3.One, Vector3.Zero, 0, 0, 0, 7)
|
||||
{
|
||||
Ocean = new() { DepthMeters = 4000 },
|
||||
OceanMask = new(2, 2, [1, 1, 1, 1]),
|
||||
Terrain = new() { HeightMap = new(2, 2, [.5f, .5f, .5f, .5f]), HeightmapMaxMeters = 1000 }
|
||||
};
|
||||
var direction = new DVector3(1, .2, .3).Normalized();
|
||||
Assert.Equal(planet.RadiusMeters - 4000, TerrainHeight.Sample(planet, direction), 6);
|
||||
var land = planet with { OceanMask = new(2, 2, [0, 0, 0, 0]) };
|
||||
Assert.Equal(planet.RadiusMeters + 500, TerrainHeight.Sample(land, direction), 6);
|
||||
var size = UniverseScale.DefaultCellSizeMeters;
|
||||
var origin = UniversePosition.AtCellCenter(new(0, 0, 0), size);
|
||||
var start = origin.Add(direction * (planet.RadiusMeters + 10), size);
|
||||
var underwater = origin.Add(direction * (planet.RadiusMeters - 20), size);
|
||||
Assert.False(PlanetCollisionSolver.Sweep(start, underwater, origin, [planet], size).Collided);
|
||||
var bottom = origin.Add(direction * (planet.RadiusMeters - 4100), size);
|
||||
var contact = PlanetCollisionSolver.Sweep(start, bottom, origin, [planet], size);
|
||||
Assert.True(contact.Collided);
|
||||
Assert.InRange(contact.Position.RelativeTo(origin, size).Length - planet.RadiusMeters, -3998.6, -3998.4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpectrumHasStableSwellAndBoundedFineRipples()
|
||||
{
|
||||
var settings = new OceanSettings();
|
||||
var a = OceanSurface.Spectrum(settings, 7);
|
||||
Assert.Equal(a, OceanSurface.Spectrum(settings, 7));
|
||||
Assert.NotEqual(a, OceanSurface.Spectrum(settings, 8));
|
||||
Assert.True(a[0].WaveNumber < a[^1].WaveNumber);
|
||||
Assert.True(a[^1].Amplitude < .01);
|
||||
Assert.InRange(a.Sum(w => w.Amplitude), settings.WaveAmplitudeMeters, settings.WaveAmplitudeMeters * 3);
|
||||
Assert.All(a, w => Assert.InRange(w.Amplitude * w.WaveNumber, 0, .2));
|
||||
}
|
||||
}
|
||||
57
tests/Universe.Tests/PlayerControllerTests.cs
Normal file
57
tests/Universe.Tests/PlayerControllerTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Numerics;
|
||||
using Universe.App.Runtime;
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class PlayerControllerTests
|
||||
{
|
||||
private const double CellSize = 1_000_000;
|
||||
private static (PlayerController Player, UniverseNeighborhood World, UniversePosition Center, PlanetData Planet) Create(double height)
|
||||
{
|
||||
var planet = new PlanetData(0, "Test", DVector3.Zero, 10_000, 0, 0, Vector3.One, Vector3.Zero, 0, 0, 0, 10);
|
||||
var system = new StarSystemData(new(0, 0, 0), 1, [], [planet]);
|
||||
var world = new UniverseNeighborhood(system, []);
|
||||
var center = UniversePosition.AtCellCenter(system.Coordinate, CellSize);
|
||||
var start = center.Add(new DVector3(0, planet.RadiusMeters + height, 0), CellSize);
|
||||
return (new PlayerController(start, new(1, 0, 0), new(0, 1, 0), CellSize, world), world, center, planet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GravityLandsAndWalkingJumpingAndCrouchingWorkOnCurvedGround()
|
||||
{
|
||||
var (player, world, center, planet) = Create(15);
|
||||
for (var i = 0; i < 500; i++) player.Update(1d / 60, 0, 0, false, false, world);
|
||||
Assert.True(player.Grounded);
|
||||
var landed = player.Position.RelativeTo(center, CellSize);
|
||||
Assert.InRange(landed.Length - planet.RadiusMeters, 1.69, 1.71);
|
||||
for (var i = 0; i < 60; i++) player.Update(1d / 60, 1, 0, false, false, world);
|
||||
var walked = player.Position.RelativeTo(center, CellSize);
|
||||
Assert.InRange((walked - landed).Length, 4.4, 4.6);
|
||||
Assert.InRange(DVector3.Dot(player.Up, walked.Normalized()), .999999, 1.000001);
|
||||
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(player.Position.RelativeTo(center, CellSize).Length > planet.RadiusMeters + 2.0);
|
||||
for (var i = 0; i < 600; i++) player.Update(1d / 60, 0, 0, false, false, world);
|
||||
Assert.True(player.Grounded);
|
||||
player.Update(1d / 60, 0, 0, false, true, world);
|
||||
Assert.True(player.Crouching);
|
||||
Assert.InRange(player.Position.RelativeTo(center, CellSize).Length - planet.RadiusMeters, .99, 1.01);
|
||||
player.Update(1d / 60, 0, 0, false, false, world);
|
||||
Assert.InRange(player.Position.RelativeTo(center, CellSize).Length - planet.RadiusMeters, 1.69, 1.71);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistantDescentMovesTowardPlanetWithoutTunneling()
|
||||
{
|
||||
var (player, world, center, planet) = Create(30_000);
|
||||
var start = player.Position.RelativeTo(center, CellSize).Length;
|
||||
for (var i = 0; i < 600; i++) player.Update(1d / 60, 0, 0, false, false, world);
|
||||
var end = player.Position.RelativeTo(center, CellSize).Length;
|
||||
Assert.True(end < start - 10_000);
|
||||
Assert.True(end >= planet.RadiusMeters + 1.69);
|
||||
}
|
||||
}
|
||||
32
tests/Universe.Tests/RegionalHeightTests.cs
Normal file
32
tests/Universe.Tests/RegionalHeightTests.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class RegionalHeightTests
|
||||
{
|
||||
private static DVector3 Direction(double lon, double lat)
|
||||
{
|
||||
var a = -lon * System.Math.PI / 180;
|
||||
var b = lat * System.Math.PI / 180;
|
||||
return new(System.Math.Cos(b) * System.Math.Cos(a), System.Math.Sin(b), System.Math.Cos(b) * System.Math.Sin(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegionalHeightsAreGeoreferencedAndFadeToGlobalTerrain()
|
||||
{
|
||||
var map = new RegionalHeightMap { Width = 2, Height = 2, Meters = [1000, 2000, 3000, 4000],
|
||||
WestDegrees = 7, EastDegrees = 8, SouthDegrees = 46, NorthDegrees = 47, FadeDegrees = .1, MaximumMeters = 4000, MinimumMeters = 1000 };
|
||||
var center = map.Sample(Direction(7.5, 46.5));
|
||||
Assert.Equal(2500, center.Height, 4);
|
||||
Assert.Equal(1, center.Weight, 4);
|
||||
var boundary = map.Sample(Direction(7.025, 46.5));
|
||||
Assert.InRange(boundary.Weight, .15, .17);
|
||||
Assert.Equal(0, map.Sample(Direction(9, 46.5)).Weight);
|
||||
|
||||
var planet = new PlanetData(0, "test", DVector3.Zero, 6_000_000, 0, 0, default, default, 0, 0, 0, 1)
|
||||
{ Terrain = new() { RegionalHeight = map } };
|
||||
Assert.Equal(6_002_500, TerrainHeight.Sample(planet, Direction(7.5, 46.5)), 3);
|
||||
Assert.Equal(6_000_000, TerrainHeight.Sample(planet, Direction(9, 46.5)), 3);
|
||||
}
|
||||
}
|
||||
99
tests/Universe.Tests/TerrainAndCollisionTests.cs
Normal file
99
tests/Universe.Tests/TerrainAndCollisionTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.Physics;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class TerrainAndCollisionTests
|
||||
{
|
||||
[Fact]
|
||||
public void LodAddsSegmentsAsCameraApproaches()
|
||||
{
|
||||
const double radius = 6_000_000;
|
||||
|
||||
var far = TerrainLodSelector.SegmentsForLevel(TerrainLodSelector.SelectLevel(radius * 30, radius));
|
||||
var near = TerrainLodSelector.SegmentsForLevel(TerrainLodSelector.SelectLevel(radius * 1.1, radius));
|
||||
|
||||
Assert.True(near > far);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBuilderCreatesIndexedSphereFace()
|
||||
{
|
||||
var planet = new UniverseGenerator(77).Generate(new(0, 0, 0)).Planets[0];
|
||||
var chunk = TerrainChunkBuilder.BuildFace(planet, face: 0, segments: 8);
|
||||
|
||||
Assert.Equal(9 * 9, chunk.VertexCount);
|
||||
Assert.Equal(8 * 8 * 6, chunk.Indices.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SweptCollisionStopsFtlMovementThroughPlanet()
|
||||
{
|
||||
const double cellSize = 100d * UniverseScale.AstronomicalUnitMeters;
|
||||
var system = new UniverseGenerator(91).Generate(new(0, 0, 0));
|
||||
var planet = system.Planets[0];
|
||||
var systemCenter = UniversePosition.AtCellCenter(system.Coordinate, cellSize);
|
||||
var start = systemCenter.Add(planet.PositionMeters + new DVector3(-planet.RadiusMeters * 5, 0, 0), cellSize);
|
||||
var end = systemCenter.Add(planet.PositionMeters + new DVector3(planet.RadiusMeters * 5, 0, 0), cellSize);
|
||||
|
||||
var result = PlanetCollisionSolver.Sweep(start, end, systemCenter, system.Planets, cellSize);
|
||||
|
||||
Assert.True(result.Collided);
|
||||
Assert.Equal(planet.Index, result.PlanetIndex);
|
||||
Assert.NotEqual(end, result.Position);
|
||||
var relative = result.Position.RelativeTo(systemCenter, cellSize) - planet.PositionMeters;
|
||||
var distance = relative.Length;
|
||||
var surface = TerrainHeight.Sample(planet, relative.Normalized());
|
||||
Assert.InRange(distance, surface + 1.0, surface + 3.0);
|
||||
|
||||
var outward = result.Position.Add(relative.Normalized() * 100, cellSize);
|
||||
var release = PlanetCollisionSolver.Sweep(result.Position, outward, systemCenter, system.Planets, cellSize);
|
||||
Assert.False(release.Collided);
|
||||
Assert.Equal(outward, release.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentTerrainTilesMeetAtTheSameWorldPositions()
|
||||
{
|
||||
var planet = new UniverseGenerator(55).Generate(new(0, 0, 0)).Planets[0];
|
||||
var left = TerrainPatchBuilder.Build(planet, new(0, 3, 2, 3));
|
||||
var right = TerrainPatchBuilder.Build(planet, new(0, 3, 3, 3));
|
||||
const int side = TerrainPatchBuilder.Segments + 1;
|
||||
for (var y = 0; y < side; y++)
|
||||
{
|
||||
var l = Vertex(left, y * side + side - 1);
|
||||
var r = Vertex(right, y * side);
|
||||
Assert.InRange((l - r).Length, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MountainNormalsFollowReliefAndFaceOutward()
|
||||
{
|
||||
var planet = new UniverseGenerator(55).Generate(new(0, 0, 0)).Planets[0];
|
||||
var angles = Enumerable.Range(0, 80).Select(i => new DVector3(System.Math.Cos(i * 0.5), 0.4, System.Math.Sin(i * 0.5)).Normalized()).ToArray();
|
||||
var dots = angles.Select(d => DVector3.Dot(d, TerrainHeight.Normal(planet, d))).ToArray();
|
||||
Assert.All(dots, dot => Assert.InRange(dot, 0.01, 1.000001));
|
||||
Assert.Contains(dots, dot => dot < 0.98);
|
||||
}
|
||||
|
||||
private static DVector3 Vertex(TerrainPatchData patch, int index) => patch.Origin + new DVector3(
|
||||
patch.Vertices[index * 9], patch.Vertices[index * 9 + 1], patch.Vertices[index * 9 + 2]);
|
||||
|
||||
[Fact]
|
||||
public void SeamCoverageStaysBelowTheSurfaceAndPreservesItsShadingNormal()
|
||||
{
|
||||
var planet = new UniverseGenerator(55).Generate(new(0, 0, 0)).Planets[0];
|
||||
var patch = TerrainPatchBuilder.Build(planet, new(0, 5, 11, 7));
|
||||
const int side = TerrainPatchBuilder.Segments + 1;
|
||||
var skirtIndex = side * side;
|
||||
var surface = Vertex(patch, 0);
|
||||
var recessed = Vertex(patch, skirtIndex);
|
||||
Assert.True(recessed.Length < surface.Length);
|
||||
Assert.True(DVector3.Dot((surface - recessed).Normalized(), surface.Normalized()) > 0.999);
|
||||
for (var component = 3; component < 6; component++)
|
||||
Assert.Equal(patch.Vertices[component], patch.Vertices[skirtIndex * 9 + component]);
|
||||
}
|
||||
}
|
||||
126
tests/Universe.Tests/TerrainVisibilityTests.cs
Normal file
126
tests/Universe.Tests/TerrainVisibilityTests.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.Math;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class TerrainVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void HiddenHemisphereIsRejectedWithoutDiscardingNearOrHorizonPatches()
|
||||
{
|
||||
const double inner = 980, outer = 1020;
|
||||
var camera = new DVector3(0, 0, 1050);
|
||||
var angle = TerrainVisibility.HorizonAngle(camera, inner, outer);
|
||||
Assert.True(TerrainVisibility.BehindHorizon(new(5, 0, 0, 0), camera.Normalized(), angle));
|
||||
Assert.False(TerrainVisibility.BehindHorizon(new(4, 0, 0, 0), camera.Normalized(), angle));
|
||||
Assert.Equal(System.Math.PI, TerrainVisibility.HorizonAngle(new(0, 0, 970), inner, outer));
|
||||
|
||||
// Every sampled point of a rejected tile must have an occluding intersection,
|
||||
// including tall mountains on the far side and positions near the horizon.
|
||||
var random = new Random(828);
|
||||
for (var trial = 0; trial < 1200; trial++)
|
||||
{
|
||||
var level = random.Next(0, 7); var count = 1 << level;
|
||||
var patch = new TerrainPatchKey(random.Next(0, 6), level, random.Next(count), random.Next(count));
|
||||
if (!TerrainVisibility.BehindHorizon(patch, camera.Normalized(), angle)) continue;
|
||||
for (var y = 0; y <= 4; y++) for (var x = 0; x <= 4; x++)
|
||||
{
|
||||
var u = -1 + (patch.X + x / 4d) * patch.Width;
|
||||
var v = -1 + (patch.Y + y / 4d) * patch.Width;
|
||||
var point = TerrainChunkBuilder.FaceDirection(patch.Face, u, v).Normalized() * outer;
|
||||
var ray = point - camera;
|
||||
var t = System.Math.Clamp(-DVector3.Dot(camera, ray) / ray.LengthSquared, 0, 1);
|
||||
Assert.True((camera + ray * t).Length < inner + 1e-7, $"Visible point was rejected in {patch}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearbyPatchesReceiveMoreDetailThanDistantPatches()
|
||||
{
|
||||
var camera = new DVector3(0, 0, 1005);
|
||||
var underCamera = new TerrainPatchKey(4, 4, 8, 8);
|
||||
var distant = new TerrainPatchKey(4, 4, 1, 1);
|
||||
Assert.InRange(TerrainVisibility.DistanceToPatch(underCamera, camera, 1000), 4.99, 5.01);
|
||||
Assert.True(TerrainVisibility.DistanceToPatch(distant, camera, 1000) > 100);
|
||||
Assert.True(TerrainVisibility.PixelTarget(100) < TerrainVisibility.PixelTarget(50_000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrustumRejectionKeepsEverySampleThatCanReachTheScreen()
|
||||
{
|
||||
var camera = new DVector3(0, 0, 1005);
|
||||
var forward = new DVector3(0, 1, 0);
|
||||
var right = new DVector3(1, 0, 0);
|
||||
var up = new DVector3(0, 0, 1);
|
||||
const double verticalTan = .7, aspect = 1.6, low = 970, high = 1030;
|
||||
var random = new Random(915);
|
||||
var rejected = 0;
|
||||
for (var trial = 0; trial < 1800; trial++)
|
||||
{
|
||||
var level = random.Next(0, 7); var count = 1 << level;
|
||||
var patch = new TerrainPatchKey(random.Next(6), level, random.Next(count), random.Next(count));
|
||||
if (!TerrainVisibility.OutsideFrustum(patch, camera, forward, right, up, verticalTan, aspect, low, high)) continue;
|
||||
rejected++;
|
||||
for (var y = 0; y <= 3; y++) for (var x = 0; x <= 3; x++) foreach (var radius in new[] { low, (low + high) / 2, high })
|
||||
{
|
||||
var u = -1 + (patch.X + x / 3d) * patch.Width;
|
||||
var v = -1 + (patch.Y + y / 3d) * patch.Width;
|
||||
var point = TerrainChunkBuilder.FaceDirection(patch.Face, u, v).Normalized() * radius - camera;
|
||||
var z = DVector3.Dot(point, forward);
|
||||
var onScreen = z >= 0 && System.Math.Abs(DVector3.Dot(point, up)) <= z * verticalTan
|
||||
&& System.Math.Abs(DVector3.Dot(point, right)) <= z * verticalTan * aspect;
|
||||
Assert.False(onScreen, $"Visible point was rejected in {patch}.");
|
||||
}
|
||||
}
|
||||
Assert.True(rejected > 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeometryBoundsContainAllSurfaceAndSkirtVertices()
|
||||
{
|
||||
var planet = new UniverseGenerator(17).Generate(new(0, 0, 0)).Planets[0];
|
||||
foreach (var level in new[] { 0, 5, 12, 19 })
|
||||
{
|
||||
var patch = TerrainPatchBuilder.Build(planet, new(0, level, 0, 0));
|
||||
for (var i = 0; i < patch.Vertices.Length; i += 9)
|
||||
{
|
||||
var point = patch.Origin + new DVector3(patch.Vertices[i], patch.Vertices[i + 1], patch.Vertices[i + 2]);
|
||||
Assert.True((point - patch.BoundsCenter).Length <= patch.BoundsRadius + 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentLevelsKeepEnoughSkirtOverlapWithoutHugeNearWalls()
|
||||
{
|
||||
var planet = new UniverseGenerator(17).Generate(new(0, 0, 0)).Planets[0];
|
||||
foreach (var level in new[] { 7, 12, 17 })
|
||||
{
|
||||
var x = (1 << (level - 1)) - 1; var y = 1 << (level - 1);
|
||||
var coarseKey = new TerrainPatchKey(0, level, x, y);
|
||||
var fineKey = new TerrainPatchKey(0, level + 1, (x + 1) * 2, y * 2);
|
||||
var coarse = TerrainPatchBuilder.Build(planet, coarseKey);
|
||||
var fine = TerrainPatchBuilder.Build(planet, fineKey);
|
||||
const int segments = TerrainPatchBuilder.Segments, side = segments + 1;
|
||||
static DVector3 Vertex(TerrainPatchData patch, int index) => patch.Origin + new DVector3(
|
||||
patch.Vertices[index * 9], patch.Vertices[index * 9 + 1], patch.Vertices[index * 9 + 2]);
|
||||
static double Skirt(PlanetData body, TerrainPatchKey key)
|
||||
{
|
||||
var spacing = body.RadiusMeters * key.Width / segments;
|
||||
return spacing * 2 + System.Math.Min(body.TerrainAmplitudeMeters * .012, spacing * 2);
|
||||
}
|
||||
for (var i = 0; i <= segments; i++)
|
||||
{
|
||||
var j = i / 2; var fraction = (i % 2) * .5;
|
||||
var a = Vertex(coarse, j * side + segments);
|
||||
var b = Vertex(coarse, System.Math.Min(segments, j + 1) * side + segments);
|
||||
var coarsePoint = DVector3.Lerp(a, b, fraction);
|
||||
var finePoint = Vertex(fine, i * side);
|
||||
var difference = finePoint.Length - coarsePoint.Length;
|
||||
var available = difference >= 0 ? Skirt(planet, fineKey) : Skirt(planet, coarseKey);
|
||||
Assert.True(System.Math.Abs(difference) <= available + .1, $"Level {level}: {difference} m gap exceeds {available} m skirt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tests/Universe.Tests/Universe.Tests.csproj
Normal file
27
tests/Universe.Tests/Universe.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Universe.Core\Universe.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Universe.App\Universe.App.csproj" />
|
||||
<Content Include="..\..\config.json" Link="config.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="..\..\KnownSolarSystem\**\*" Link="KnownSolarSystem\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
98
tests/Universe.Tests/UniverseGenerationTests.cs
Normal file
98
tests/Universe.Tests/UniverseGenerationTests.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Universe.Core.Generation;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class UniverseGenerationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SameSeedAndCoordinateGenerateIdenticalSystems()
|
||||
{
|
||||
var generator = new UniverseGenerator(0xC0FFEEUL);
|
||||
var coordinate = new SectorCoordinate(-18, 200, 4);
|
||||
|
||||
var first = generator.Generate(coordinate);
|
||||
var second = generator.Generate(coordinate);
|
||||
|
||||
Assert.Equal(first.Seed, second.Seed);
|
||||
Assert.Equal(first.Stars, second.Stars);
|
||||
Assert.Equal(first.Planets, second.Planets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentCoordinatesProduceDifferentSeeds()
|
||||
{
|
||||
var generator = new UniverseGenerator(1234);
|
||||
Assert.NotEqual(generator.SeedFor(new(4, 8, 15)), generator.SeedFor(new(4, 8, 16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainIsDeterministicAndFinite()
|
||||
{
|
||||
var planet = new UniverseGenerator(55).Generate(new(0, 0, 0)).Planets[0];
|
||||
var direction = new Universe.Core.Math.DVector3(1, 0.3, -0.4).Normalized();
|
||||
|
||||
var first = TerrainHeight.Sample(planet, direction);
|
||||
var second = TerrainHeight.Sample(planet, direction);
|
||||
|
||||
Assert.True(double.IsFinite(first));
|
||||
Assert.Equal(first, second);
|
||||
Assert.True(first > planet.RadiusMeters - planet.TerrainAmplitudeMeters);
|
||||
Assert.True(first < planet.RadiusMeters + planet.TerrainAmplitudeMeters * 1.3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeighborhoodContainsActiveSystemAndAdjacentSystems()
|
||||
{
|
||||
var systems = new UniverseGenerator(8).GenerateNeighborhood(new(0, 0, 0)).ToArray();
|
||||
Assert.Equal(27, systems.Length);
|
||||
Assert.Contains(systems, system => system.Coordinate == new SectorCoordinate(0, 0, 0));
|
||||
Assert.Contains(systems, system => system.Coordinate == new SectorCoordinate(1, -1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeneratedBodiesHaveThreeDimensionalPlacementAndStayInsideTheirCell()
|
||||
{
|
||||
var generator = new UniverseGenerator(0x554E495645525345UL);
|
||||
var systems = generator.GenerateNeighborhood(new(0, 0, 0)).ToArray();
|
||||
Assert.All(systems, system =>
|
||||
{
|
||||
Assert.Contains(system.Stars, star => System.Math.Abs(star.PositionMeters.Y) > 1000);
|
||||
Assert.True(system.Planets.Max(p => p.PositionMeters.Y) - system.Planets.Min(p => p.PositionMeters.Y) > 1_000_000);
|
||||
foreach (var planet in system.Planets)
|
||||
{
|
||||
var limit = generator.CellSizeMeters / 2 - planet.MaximumTerrainRadiusMeters;
|
||||
Assert.InRange(planet.PositionMeters.X, -limit, limit);
|
||||
Assert.InRange(planet.PositionMeters.Y, -limit, limit);
|
||||
Assert.InRange(planet.PositionMeters.Z, -limit, limit);
|
||||
Assert.All(system.Stars, star => Assert.True((planet.PositionMeters - star.PositionMeters).Length > planet.MaximumTerrainRadiusMeters + star.RadiusMeters));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoiseLatticeRepeatsContinuouslyAtTextureBoundaries()
|
||||
{
|
||||
var p = new Universe.Core.Math.DVector3(-2.123, 65.75, 8.512);
|
||||
var a = ProceduralNoise.Sample(p);
|
||||
var b = ProceduralNoise.Sample(p + new Universe.Core.Math.DVector3(64, -64, 128));
|
||||
Assert.InRange(System.Math.Abs(a - b), 0, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoonsHaveValidParentsAndClearTheParentSurface()
|
||||
{
|
||||
foreach (var system in new UniverseGenerator(123).GenerateNeighborhood(new(0, 0, 0)))
|
||||
{
|
||||
Assert.InRange(system.Planets.Count, 1, 16);
|
||||
var moons = system.Planets.Where(p => p.ParentPlanetIndex.HasValue).ToArray();
|
||||
Assert.NotEmpty(moons);
|
||||
foreach (var moon in moons)
|
||||
{
|
||||
var parent = Assert.Single(system.Planets, p => p.Index == moon.ParentPlanetIndex);
|
||||
Assert.Null(parent.ParentPlanetIndex);
|
||||
Assert.True((moon.PositionMeters - parent.PositionMeters).Length > parent.MaximumTerrainRadiusMeters + moon.MaximumTerrainRadiusMeters);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
tests/Universe.Tests/UniversePositionTests.cs
Normal file
40
tests/Universe.Tests/UniversePositionTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Universe.Core.Math;
|
||||
using Universe.Core.World;
|
||||
|
||||
namespace Universe.Tests;
|
||||
|
||||
public sealed class UniversePositionTests
|
||||
{
|
||||
private const double Cell = 100d;
|
||||
|
||||
[Fact]
|
||||
public void Add_NormalizesPositiveAndNegativeCellBoundaries()
|
||||
{
|
||||
var position = new UniversePosition(new(12, -4, 9), new(99, 1, 50));
|
||||
|
||||
var result = position.Add(new(4, -3, 75), Cell);
|
||||
|
||||
Assert.Equal(new SectorCoordinate(13, -5, 10), result.Sector);
|
||||
Assert.Equal(new DVector3(3, 98, 25), result.LocalOffset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeTo_RetainsAdjacentCellPrecisionAtLargeCoordinates()
|
||||
{
|
||||
var origin = UniversePosition.AtCellCenter(new(long.MaxValue - 10, 3, -2), Cell);
|
||||
var adjacent = origin.Add(new(Cell + 0.125, 0, 0), Cell);
|
||||
|
||||
var relative = adjacent.RelativeTo(origin, Cell);
|
||||
|
||||
Assert.Equal(Cell + 0.125, relative.X, 8);
|
||||
Assert.Equal(0, relative.Y, 8);
|
||||
Assert.Equal(0, relative.Z, 8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_ThrowsWhenSectorRangeIsExceeded()
|
||||
{
|
||||
var position = new UniversePosition(new(long.MaxValue, 0, 0), new(99, 0, 0));
|
||||
Assert.Throws<OverflowException>(() => position.Add(new(2, 0, 0), Cell));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user